@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.
@@ -10,6 +10,54 @@ let _camstack_types_node = require("@camstack/types/node");
10
10
  let node_child_process = require("node:child_process");
11
11
  let sharp = require("sharp");
12
12
  sharp = require_chunk.__toESM(sharp);
13
+ //#region src/builtins/snapshot/snapshot-cache.ts
14
+ /** A request with no explicit stream is its OWN entry, not a wildcard: the
15
+ * per-device preference decides what it captures, and conflating it with an
16
+ * explicit request is how the original bug read. */
17
+ var AUTO = "auto";
18
+ function keyOf(deviceId, streamId) {
19
+ return `${deviceId}:${streamId ?? AUTO}`;
20
+ }
21
+ var SnapshotCache = class {
22
+ byKey = /* @__PURE__ */ new Map();
23
+ /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
24
+ keysByDevice = /* @__PURE__ */ new Map();
25
+ get(deviceId, streamId) {
26
+ return this.byKey.get(keyOf(deviceId, streamId));
27
+ }
28
+ set(deviceId, streamId, entry) {
29
+ const key = keyOf(deviceId, streamId);
30
+ this.byKey.set(key, entry);
31
+ const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
32
+ keys.add(key);
33
+ this.keysByDevice.set(deviceId, keys);
34
+ }
35
+ /** The newest entry for a device, whichever stream produced it. */
36
+ latest(deviceId) {
37
+ let newest;
38
+ for (const key of this.keysByDevice.get(deviceId) ?? []) {
39
+ const entry = this.byKey.get(key);
40
+ if (entry !== void 0 && (newest === void 0 || entry.ts > newest.ts)) newest = entry;
41
+ }
42
+ return newest;
43
+ }
44
+ /**
45
+ * Drop every stream of one device.
46
+ *
47
+ * Device-wide on purpose: a settings change or an operator refresh
48
+ * invalidates the CAMERA, and leaving a sibling entry behind would keep
49
+ * serving the pre-change frame from the other key.
50
+ */
51
+ deleteDevice(deviceId) {
52
+ for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
53
+ this.keysByDevice.delete(deviceId);
54
+ }
55
+ clear() {
56
+ this.byKey.clear();
57
+ this.keysByDevice.clear();
58
+ }
59
+ };
60
+ //#endregion
13
61
  //#region src/builtins/snapshot/snapshot-coalescing.ts
14
62
  /**
15
63
  * Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
@@ -205,563 +253,515 @@ function raceForResult(promise, timeoutMs) {
205
253
  });
206
254
  }
207
255
  //#endregion
208
- //#region src/builtins/snapshot/snapshot-media-handler.ts
256
+ //#region src/builtins/snapshot/snapshot-courtesy.ts
209
257
  /**
210
- * The widths a thumbnail may be served at.
258
+ * A courtesy frame for a camera that CANNOT produce one.
211
259
  *
212
- * A ladder, not a free integer: the width is a cache key AND an ffmpeg run, so
213
- * honouring `?w=` verbatim would let any caller mint unbounded work and
214
- * unbounded memory. Twelve tiles measured at 181–240 px collapse onto one
215
- * variant instead of twelve.
260
+ * ── Why this exists ───────────────────────────────────────────────────────
261
+ * A disabled, offline or sleeping camera has no frame, and until 2026-08-11 the
262
+ * snapshot service simply had nothing to say about it: the media route answered
263
+ * **404** and the client painted "Unavailable". Measured on the live grid that
264
+ * day, 10 of 26 tiles were 404s — one genuinely disabled camera plus nine dead
265
+ * legacy rows. A 404 is indistinguishable from a broken camera, so an operator
266
+ * who deliberately switched a camera off saw the same thing as a fault ([D62]:
267
+ * "an off switch is REPORTED off; disabled must never look like broken").
216
268
  *
217
- * Rungs are chosen for the card sizes the viewer actually renders (180–390 px)
218
- * with headroom for a device pixel ratio.
269
+ * So the service answers with a frame that SAYS what is going on, carrying the
270
+ * camera's own name. The client gets a valid image, the tile paints, and the
271
+ * state is legible instead of inferred from an error.
272
+ *
273
+ * ── Why sharp and not ffmpeg ──────────────────────────────────────────────
274
+ * The first draft of this file shelled out to `ffmpeg` with a `drawtext`
275
+ * filter. That was written before the resize path was measured, and it was the
276
+ * wrong call for the same reason: this runs in the snapshot wrapper, a system
277
+ * builtin loaded in the hub's ROOT process, so every render was a fork + exec
278
+ * on the loop that serves the API — 52 ms against sharp's 4.8 ms in that
279
+ * container, to draw two lines of text.
280
+ *
281
+ * `terminal-frame-renderer.ts` already renders text this way (SVG → sharp), so
282
+ * this is the house pattern rather than a new one. It also drops the font-PATH
283
+ * probing the ffmpeg version needed: an SVG names a font FAMILY and fontconfig
284
+ * resolves it — verified in the hub image, where `fc-match "DejaVu Sans Mono"`
285
+ * answers with the real file.
286
+ *
287
+ * Rendering is pure input → bytes with no I/O of its own, and the geometry and
288
+ * escaping are separated out so they stay testable without rasterizing.
219
289
  */
220
- var SNAPSHOT_WIDTH_LADDER = [
221
- 160,
222
- 240,
223
- 320,
224
- 480,
225
- 640,
226
- 960
227
- ];
228
- /** Snap UP to the next rung — never below what was asked, so the client is not
229
- * handed an image it has to upscale. Above the ladder, the largest rung: the
230
- * point is to stop serving the 4K original. */
231
- function snapSnapshotWidth(requested) {
232
- for (const rung of SNAPSHOT_WIDTH_LADDER) if (requested <= rung) return rung;
233
- return SNAPSHOT_WIDTH_LADDER[SNAPSHOT_WIDTH_LADDER.length - 1] ?? requested;
290
+ /**
291
+ * Font stack for the rendered text. Family names, not paths — librsvg resolves
292
+ * them through fontconfig, and the trailing generics keep a host without DejaVu
293
+ * rendering something legible instead of nothing.
294
+ */
295
+ var COURTESY_FONT_STACK = "DejaVu Sans,DejaVu Sans Mono,Helvetica,Arial,sans-serif";
296
+ /** The word the frame carries. Deliberately the operator's vocabulary. */
297
+ function courtesyLabel(reason) {
298
+ switch (reason) {
299
+ case "disabled": return "Disabled";
300
+ case "offline": return "Offline";
301
+ case "sleeping": return "Sleeping";
302
+ }
234
303
  }
235
304
  /**
236
- * Parse the handler-relative path (`/<deviceId>.jpg?query`) into a request.
237
- * Returns null for a malformed / nested / non-numeric id so the handler answers
238
- * 404 without ever reaching `getMedia`.
305
+ * Background per reason. A disabled camera is a DELIBERATE state and must not
306
+ * read as an alarm, so it is neutral grey; offline is a fault and is warmer.
239
307
  */
240
- function parseSnapshotMediaRequest(url) {
241
- const qIdx = url.indexOf("?");
242
- const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
243
- const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
244
- const segment = rawPath.replace(/^\/+/, "");
245
- if (segment.length === 0 || segment.includes("/")) return null;
246
- const idPart = segment.replace(/\.jpe?g$/i, "");
247
- if (!/^\d+$/.test(idPart)) return null;
248
- const deviceId = Number.parseInt(idPart, 10);
249
- if (!Number.isSafeInteger(deviceId)) return null;
250
- const params = new URLSearchParams(query);
251
- const rawStream = params.get("streamId");
252
- const streamId = rawStream !== null && rawStream.length > 0 ? rawStream : void 0;
253
- const rawForce = params.get("force");
254
- const force = rawForce === "1" || rawForce === "true";
255
- const rawWidth = params.get("w");
256
- return {
257
- deviceId,
258
- streamId,
259
- force,
260
- width: rawWidth !== null && /^\d+$/.test(rawWidth) && Number.parseInt(rawWidth, 10) > 0 ? snapSnapshotWidth(Number.parseInt(rawWidth, 10)) : void 0
261
- };
308
+ function courtesyBackground(reason) {
309
+ switch (reason) {
310
+ case "disabled": return "#2b2b31";
311
+ case "offline": return "#3a2b2b";
312
+ case "sleeping": return "#232b3a";
313
+ }
314
+ }
315
+ /** XML escaping for text placed inside an SVG `<text>` node. */
316
+ function escapeCourtesyText(value) {
317
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
262
318
  }
263
319
  /**
264
- * The response's entity tag.
320
+ * The SVG the frame is rasterized from: the state large and centred, the
321
+ * camera's name under it, smaller and dimmer.
265
322
  *
266
- * It has to identify the VARIANT, not just the frame: the same capture can now
267
- * be served at several widths and from several streams, and a plain
268
- * `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
269
- * 304 for `?w=960` and render the small image at full size. The base form is
270
- * unchanged for a plain request, so the identity
271
- * `snapshot.getSnapshotOverview` advertises still matches the frame nobody
272
- * asked to resize.
323
+ * Sizes derive from the width so a 240 px grid tile and a 1920 px full-bleed
324
+ * frame read the same, with a floor so a thumbnail stays legible.
273
325
  */
274
- function snapshotEtag(variant, capturedAt) {
275
- const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
276
- if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
277
- if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
278
- return `"${parts.join("-")}"`;
326
+ function buildCourtesySvg(spec) {
327
+ const stateSize = Math.max(12, Math.round(spec.width / 12));
328
+ const nameSize = Math.max(9, Math.round(spec.width / 26));
329
+ const state = escapeCourtesyText(courtesyLabel(spec.reason));
330
+ const name = escapeCourtesyText(spec.deviceName);
331
+ const midY = spec.height / 2;
332
+ return [
333
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
334
+ `<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
335
+ `<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle">`,
336
+ `<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
337
+ `<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
338
+ `</g></svg>`
339
+ ].join("");
340
+ }
341
+ /** Cache key — a courtesy frame is a pure function of these four. */
342
+ function courtesyCacheKey(spec) {
343
+ return `${spec.reason}:${String(spec.width)}x${String(spec.height)}:${spec.deviceName}`;
279
344
  }
280
345
  /**
281
- * Create a data-plane handler that serves per-device snapshots as JPEG images.
282
- * `deps.getMedia` is called once per request; null → 404, throw → 500. A
283
- * conditional GET with a matching `If-None-Match` produces a 304.
346
+ * Render the frame.
347
+ *
348
+ * Rejects on failure the caller decides what to do, exactly as `resizeJpeg`
349
+ * does, so a broken courtesy path is never mistaken for a broken camera.
284
350
  */
285
- function createSnapshotMediaHandler(deps) {
286
- return async (req, res) => {
287
- if (req.method !== "GET" && req.method !== "HEAD") {
288
- res.writeHead(405, { allow: "GET, HEAD" }).end();
289
- return;
290
- }
291
- const parsed = parseSnapshotMediaRequest(req.url ?? "/");
292
- if (parsed === null) {
293
- res.writeHead(404).end();
294
- return;
295
- }
296
- const inm = req.headers["if-none-match"];
297
- if (typeof inm === "string" && !parsed.force && deps.peekFresh !== void 0) {
298
- let peeked = null;
299
- try {
300
- peeked = await deps.peekFresh(parsed.deviceId, parsed.streamId);
301
- } catch {
302
- peeked = null;
303
- }
304
- if (peeked !== null) {
305
- const peekedEtag = snapshotEtag({
306
- deviceId: parsed.deviceId,
307
- streamId: parsed.streamId,
308
- width: parsed.width
309
- }, peeked.capturedAt);
310
- if (inm === peekedEtag) {
311
- res.writeHead(304, {
312
- etag: peekedEtag,
313
- "cache-control": `private, max-age=${Math.max(0, Math.floor(peeked.maxAgeS))}`
314
- }).end();
315
- return;
316
- }
317
- }
318
- }
319
- let media;
320
- try {
321
- media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
322
- } catch {
323
- const body = "Internal server error";
324
- res.writeHead(500, {
325
- "content-type": "text/plain",
326
- "content-length": String(Buffer.byteLength(body))
327
- });
328
- if (req.method === "HEAD") res.end();
329
- else res.end(body);
330
- return;
331
- }
332
- if (media === null) {
333
- res.writeHead(404).end();
334
- return;
335
- }
336
- const etag = snapshotEtag({
337
- deviceId: parsed.deviceId,
338
- streamId: parsed.streamId,
339
- width: "servedWidth" in media ? media.servedWidth : parsed.width
340
- }, media.capturedAt);
341
- const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
342
- if (inm === etag) {
343
- res.writeHead(304, {
344
- etag,
345
- "cache-control": cacheControl
346
- }).end();
347
- return;
348
- }
349
- res.writeHead(200, {
350
- "content-type": media.contentType,
351
- "cache-control": cacheControl,
352
- etag,
353
- "content-length": String(media.bytes.byteLength)
354
- });
355
- if (req.method === "HEAD") res.end();
356
- else res.end(Buffer.from(media.bytes));
357
- };
351
+ function renderCourtesyJpeg(spec) {
352
+ return (0, sharp.default)(Buffer.from(buildCourtesySvg(spec))).jpeg({ quality: 82 }).toBuffer().then((bytes) => {
353
+ if (bytes.length === 0) throw new Error("courtesy frame produced no bytes");
354
+ return bytes;
355
+ });
358
356
  }
359
357
  //#endregion
360
- //#region src/builtins/snapshot/snapshot-cache.ts
361
- /** A request with no explicit stream is its OWN entry, not a wildcard: the
362
- * per-device preference decides what it captures, and conflating it with an
363
- * explicit request is how the original bug read. */
364
- var AUTO = "auto";
365
- function keyOf(deviceId, streamId) {
366
- return `${deviceId}:${streamId ?? AUTO}`;
367
- }
368
- var SnapshotCache = class {
369
- byKey = /* @__PURE__ */ new Map();
370
- /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
371
- keysByDevice = /* @__PURE__ */ new Map();
372
- get(deviceId, streamId) {
373
- return this.byKey.get(keyOf(deviceId, streamId));
374
- }
375
- set(deviceId, streamId, entry) {
376
- const key = keyOf(deviceId, streamId);
377
- this.byKey.set(key, entry);
378
- const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
379
- keys.add(key);
380
- this.keysByDevice.set(deviceId, keys);
381
- }
382
- /** The newest entry for a device, whichever stream produced it. */
383
- latest(deviceId) {
384
- let newest;
385
- for (const key of this.keysByDevice.get(deviceId) ?? []) {
386
- const entry = this.byKey.get(key);
387
- if (entry !== void 0 && (newest === void 0 || entry.ts > newest.ts)) newest = entry;
388
- }
389
- return newest;
390
- }
391
- /**
392
- * Drop every stream of one device.
393
- *
394
- * Device-wide on purpose: a settings change or an operator refresh
395
- * invalidates the CAMERA, and leaving a sibling entry behind would keep
396
- * serving the pre-change frame from the other key.
397
- */
398
- deleteDevice(deviceId) {
399
- for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
400
- this.keysByDevice.delete(deviceId);
401
- }
402
- clear() {
403
- this.byKey.clear();
404
- this.keysByDevice.clear();
405
- }
406
- };
407
- //#endregion
408
- //#region src/builtins/snapshot/snapshot-resize.ts
358
+ //#region src/builtins/snapshot/snapshot-link-url.ts
409
359
  /**
410
- * Downscaling a captured frame to a card-sized thumbnail.
360
+ * Signed, expiring links to a CLIENT-SIZED snapshot frame.
411
361
  *
412
- * Applied AFTER capture rather than during it, and that is deliberate: the two
413
- * capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
414
- * grab) produce a JPEG by different routes, and only one of them has an
415
- * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
416
- * same behaviour with one implementation.
362
+ * ## Why a link plane exists at all
417
363
  *
418
- * ── Why sharp and not ffmpeg (2026-08-11) ─────────────────────────────────
419
- * This used to `spawn('ffmpeg')` per resize. The snapshot wrapper is a system
420
- * builtin, so it loads in the hub's ROOT process: sampled on the live hub,
421
- * ~1.6 of those children were running at any instant, every one of them
422
- * parented by the root PID.
364
+ * The authenticated `/addon/snapshot/media/<id>.jpg` plane works, and it is not
365
+ * going away. What it cannot do is guarantee that a client asking for a tile
366
+ * actually REACHES the server and that turned out to be the whole bug.
423
367
  *
424
- * Be precise about what that cost the root process, because it is easy to
425
- * overstate. The transcode itself ran in the CHILD and was charged to ffmpeg
426
- * (16 % + 13 % of a core in that same sample), not to the parent. What the
427
- * parent paid was the fork, the exec, the JPEG written into one pipe and read
428
- * back out of the other, and the base64 — real event-loop work, on the loop
429
- * that also serves the tRPC API, but NOT the 52 ms below.
368
+ * Under D93 the image URL is versioned by the frame identity, and an image
369
+ * request is what signalled demand for a camera. Both halves are satisfied by
370
+ * the client's own image cache: `expo-image` is URL-keyed and never
371
+ * revalidates, so a URL the app painted in a previous session is served from
372
+ * disk with **zero network**. Measured on the live hub, reopening the app after
373
+ * two minutes idle painted 15 of 16 tiles from disk frames **168 s old**, with
374
+ * not one HTTP request, therefore no demand, therefore no capture. The
375
+ * operator's report ("gli snapshot sono vecchi, devo aggiornare più volte") is
376
+ * that measurement.
430
377
  *
431
- * Benchmarked in that container, 2560×1440 640 wide, wall-clock per resize:
378
+ * That gap used to be covered on BOTH sides by this link plane and by a
379
+ * server-side keep-warm timer. The timer was removed on 2026-08-11 (operator
380
+ * directive: snapshots are on-demand, always), which makes this plane the only
381
+ * thing standing between a client cache and a frozen tile. It carries the whole
382
+ * job now.
432
383
  *
433
- * ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
434
- * sharp 4.8 ms (in-process libvips, on its own threadpool)
384
+ * A minted link breaks the loop from both ends. It is produced by an RPC
385
+ * `snapshot.getSnapshotLinks` which no image cache can answer, so the demand
386
+ * signal always lands; and it carries the capture identity the RPC just WAITED
387
+ * for, rather than one a cache-only poll happened to be holding.
435
388
  *
436
- * So this removes ~29 % of a core of container CPU and the per-resize process
437
- * churn from the root process. It was NOT verified to be the cause of that
438
- * process sitting at 90 % — nobody has profiled it.
389
+ * ## What is signed, and what is only a cache key
439
390
  *
440
- * `sharp` is not a new dependency: it is already a host-external
441
- * (`HOST_EXTERNAL_SPECIFIERS`), already resolved from the framework closure at
442
- * runtime, and already used by the terminal frame renderer. The build preset
443
- * keeps it out of the bundle, so this import costs nothing at pack time.
391
+ * The signature covers `"<deviceId>:<width>"` and the expiry. The width is
392
+ * inside it deliberately: a leaked 240 px tile link must not be replayable as a
393
+ * request for the full 4 K frame. `v` (the capture identity) is NOT signed — it
394
+ * exists only to key the client's image cache, so an unchanged frame is a
395
+ * byte-identical URL and costs no bytes, and a new frame is a new URL and costs
396
+ * exactly one fetch.
444
397
  *
445
- * The cost is still one resize per (device, stream, width) per cache window —
446
- * not one per request. What changed is what a resize COSTS.
398
+ * `exp` is bucketed rather than exact. A URL that were unique per mint would
399
+ * defeat the client cache completely correct for freshness, and it would make
400
+ * a phone re-download every tile on every 5 s poll whether or not anything
401
+ * changed. Bucketing means the URL moves when the FRAME moves, and otherwise at
402
+ * most once per bucket.
403
+ *
404
+ * Pure and side-effect-free; the addon owns the secret and the clock. Unit
405
+ * tested in `__tests__/snapshot-link-url.spec.ts`.
447
406
  */
448
- var RESIZE_TIMEOUT_MS = 1e4;
449
407
  /**
450
- * Encode quality for a derived thumbnail. Matches what the ffmpeg path
451
- * produced (`-q:v 5` on the mjpeg encoder) closely enough that no card visibly
452
- * changes this migration is about COST, not about re-tuning the picture.
408
+ * How long a minted link stays valid.
409
+ *
410
+ * A snapshot is a live view of the operator's home, so this is short on purpose
411
+ * — the exposure of a leaked link is bounded by it. Two minutes is long enough
412
+ * that a page renders, re-renders and survives a brief backgrounding on the URL
413
+ * it was given, and short enough that a link pasted somewhere is dead before it
414
+ * is useful. The client re-mints on every overview poll (5 s), so it never
415
+ * depends on the tail of this window.
453
416
  */
454
- var JPEG_QUALITY = 82;
417
+ var SNAPSHOT_LINK_TTL_MS = 12e4;
455
418
  /**
456
- * Scale to `width`, preserving aspect ratio.
457
- *
458
- * Rejects on failure or timeout. It NEVER falls back to the original silently —
459
- * a caller that quietly served the 4K frame when the resize failed would
460
- * reproduce exactly the bug this whole module exists to fix, and nobody would
461
- * see it. The caller decides, and logs.
419
+ * Quantum the expiry is rounded UP to, so a link is stable between mints.
462
420
  *
463
- * It DOES upscale a source narrower than `width`, and that is deliberate. A
464
- * `withoutEnlargement: true` was tried first it is the obvious saving, since
465
- * upscaling pays encode cost for a blurrier, larger image. But the caller
466
- * stamps the response ETag from the width it asked for, and its resize-FAILURE
467
- * path already returns `width: undefined` specifically so a response can never
468
- * "claim a width the bytes do not have". Silently returning 320 px bytes for a
469
- * `w=640` request breaks that invariant on the SUCCESS path, where nobody is
470
- * looking. Honest output width beats a few saved pixels; revisit only together
471
- * with the ETag.
421
+ * Without it every mint produces a different `exp`, hence a different URL, hence
422
+ * a full re-download of an unchanged frame on every poll. With it the URL is a
423
+ * pure function of (device, width, frame, bucket) so a tile fetches when its
424
+ * frame moves, and at most once more per bucket.
472
425
  */
473
- function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
474
- const work = (0, sharp.default)(bytes).resize({ width }).jpeg({
475
- quality: JPEG_QUALITY,
476
- mozjpeg: false
477
- }).toBuffer().then((out) => {
478
- if (out.length === 0) throw new Error("snapshot resize produced no bytes");
479
- return out;
480
- });
481
- let timer;
482
- const bound = new Promise((_, reject) => {
483
- timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`)), timeoutMs);
484
- });
485
- return Promise.race([work, bound]).finally(() => {
486
- if (timer !== void 0) clearTimeout(timer);
487
- });
426
+ var SNAPSHOT_LINK_EXP_BUCKET_MS = 3e4;
427
+ /** The token the signature is computed over. Width is part of the identity so a
428
+ * tile link cannot be escalated into a full-frame request. */
429
+ function snapshotLinkId(deviceId, width) {
430
+ return `${String(deviceId)}:${width === void 0 ? "full" : String(width)}`;
431
+ }
432
+ /** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
433
+ function snapshotLinkExpiry(nowMs, ttlMs = SNAPSHOT_LINK_TTL_MS, bucketMs = SNAPSHOT_LINK_EXP_BUCKET_MS) {
434
+ return Math.ceil((nowMs + ttlMs) / bucketMs) * bucketMs;
488
435
  }
489
436
  /**
490
- * Resized frames, keyed by (device, stream, width) AND validated against the
491
- * source frame's timestamp.
437
+ * The link, as a ROOT-RELATIVE path.
492
438
  *
493
- * The timestamp is the whole correctness argument: a variant outlives nothing.
494
- * When the underlying frame is recaptured its `capturedAt` moves, every variant
495
- * derived from the old one stops matching, and the next request re-derives.
496
- * Without that check a card would keep showing a thumbnail of a frame the
497
- * full-size view had already replaced.
439
+ * Deliberately not absolute. The artifact and HA planes must mint absolute URLs
440
+ * because the fetcher is a phone or a notifier backend that has no idea where
441
+ * the hub is and picking that base is the `hubUrl: localhost` trap the Alexa
442
+ * work paid for. Here the fetcher is a client that is already connected to the
443
+ * hub and holds its own `serverUrl`, so the correct base is the one it used to
444
+ * make the call. Returning a path makes it impossible to hand a client a link
445
+ * pointing somewhere it cannot reach.
498
446
  */
499
- var SnapshotVariantCache = class SnapshotVariantCache {
500
- byKey = /* @__PURE__ */ new Map();
501
- keysByDevice = /* @__PURE__ */ new Map();
502
- static key(deviceId, streamId, width) {
503
- return `${deviceId}:${streamId ?? "auto"}:${width}`;
504
- }
505
- /** The variant for this exact frame, or undefined when it is missing or was
506
- * derived from an older capture. */
507
- get(deviceId, streamId, width, sourceTs) {
508
- const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
509
- return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
510
- }
511
- set(deviceId, streamId, width, sourceTs, bytes) {
512
- const key = SnapshotVariantCache.key(deviceId, streamId, width);
513
- this.byKey.set(key, {
514
- bytes,
515
- sourceTs
516
- });
517
- const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
518
- keys.add(key);
519
- this.keysByDevice.set(deviceId, keys);
520
- }
521
- deleteDevice(deviceId) {
522
- for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
523
- this.keysByDevice.delete(deviceId);
524
- }
525
- clear() {
526
- this.byKey.clear();
527
- this.keysByDevice.clear();
528
- }
529
- };
447
+ function buildSnapshotLinkUrl(input) {
448
+ const base = (input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`).replace(/\/+$/, "");
449
+ const id = snapshotLinkId(input.deviceId, input.width);
450
+ const sig = (0, _camstack_types_node.signExpiringUrl)(input.secret, id, input.expMs);
451
+ const params = new URLSearchParams();
452
+ if (input.width !== void 0) params.set("w", String(input.width));
453
+ if (input.capturedAt !== null) params.set("v", String(input.capturedAt));
454
+ params.set("exp", String(input.expMs));
455
+ params.set("sig", sig);
456
+ return `${base}/${String(input.deviceId)}.jpg?${params.toString()}`;
457
+ }
458
+ /**
459
+ * Parse and VERIFY a link request in one step, so a caller cannot accidentally
460
+ * use the device id before checking the signature. Null = refuse (404/403);
461
+ * there is deliberately no way to distinguish "bad signature" from "expired"
462
+ * from "malformed" at this boundary, so a public route cannot be probed.
463
+ */
464
+ function parseVerifiedSnapshotLink(input) {
465
+ const qIdx = input.url.indexOf("?");
466
+ const rawPath = qIdx === -1 ? input.url : input.url.slice(0, qIdx);
467
+ const query = qIdx === -1 ? "" : input.url.slice(qIdx + 1);
468
+ const segment = rawPath.replace(/^\/+/, "");
469
+ if (segment.length === 0 || segment.includes("/")) return null;
470
+ const idPart = segment.replace(/\.jpe?g$/i, "");
471
+ if (!/^\d+$/.test(idPart)) return null;
472
+ const deviceId = Number.parseInt(idPart, 10);
473
+ if (!Number.isSafeInteger(deviceId) || deviceId <= 0) return null;
474
+ const params = new URLSearchParams(query);
475
+ const rawWidth = params.get("w");
476
+ if (rawWidth !== null && !/^\d+$/.test(rawWidth)) return null;
477
+ const width = rawWidth === null ? void 0 : Number.parseInt(rawWidth, 10);
478
+ if (width !== void 0 && (!Number.isSafeInteger(width) || width <= 0)) return null;
479
+ return (0, _camstack_types_node.verifyExpiringUrl)({
480
+ secret: input.secret,
481
+ id: snapshotLinkId(deviceId, width),
482
+ exp: params.get("exp") ?? void 0,
483
+ sig: params.get("sig") ?? void 0,
484
+ nowMs: input.nowMs
485
+ }) ? {
486
+ deviceId,
487
+ width
488
+ } : null;
489
+ }
530
490
  //#endregion
531
- //#region src/builtins/snapshot/snapshot-courtesy.ts
491
+ //#region src/builtins/snapshot/snapshot-media-handler.ts
532
492
  /**
533
- * A courtesy frame for a camera that CANNOT produce one.
534
- *
535
- * ── Why this exists ───────────────────────────────────────────────────────
536
- * A disabled, offline or sleeping camera has no frame, and until 2026-08-11 the
537
- * snapshot service simply had nothing to say about it: the media route answered
538
- * **404** and the client painted "Unavailable". Measured on the live grid that
539
- * day, 10 of 26 tiles were 404s — one genuinely disabled camera plus nine dead
540
- * legacy rows. A 404 is indistinguishable from a broken camera, so an operator
541
- * who deliberately switched a camera off saw the same thing as a fault ([D62]:
542
- * "an off switch is REPORTED off; disabled must never look like broken").
543
- *
544
- * So the service answers with a frame that SAYS what is going on, carrying the
545
- * camera's own name. The client gets a valid image, the tile paints, and the
546
- * state is legible instead of inferred from an error.
547
- *
548
- * ── Why sharp and not ffmpeg ──────────────────────────────────────────────
549
- * The first draft of this file shelled out to `ffmpeg` with a `drawtext`
550
- * filter. That was written before the resize path was measured, and it was the
551
- * wrong call for the same reason: this runs in the snapshot wrapper, a system
552
- * builtin loaded in the hub's ROOT process, so every render was a fork + exec
553
- * on the loop that serves the API — 52 ms against sharp's 4.8 ms in that
554
- * container, to draw two lines of text.
493
+ * The widths a thumbnail may be served at.
555
494
  *
556
- * `terminal-frame-renderer.ts` already renders text this way (SVG sharp), so
557
- * this is the house pattern rather than a new one. It also drops the font-PATH
558
- * probing the ffmpeg version needed: an SVG names a font FAMILY and fontconfig
559
- * resolves it verified in the hub image, where `fc-match "DejaVu Sans Mono"`
560
- * answers with the real file.
495
+ * A ladder, not a free integer: the width is a cache key AND an ffmpeg run, so
496
+ * honouring `?w=` verbatim would let any caller mint unbounded work and
497
+ * unbounded memory. Twelve tiles measured at 181–240 px collapse onto one
498
+ * variant instead of twelve.
561
499
  *
562
- * Rendering is pure input bytes with no I/O of its own, and the geometry and
563
- * escaping are separated out so they stay testable without rasterizing.
564
- */
565
- /**
566
- * Font stack for the rendered text. Family names, not paths — librsvg resolves
567
- * them through fontconfig, and the trailing generics keep a host without DejaVu
568
- * rendering something legible instead of nothing.
500
+ * Rungs are chosen for the card sizes the viewer actually renders (180–390 px)
501
+ * with headroom for a device pixel ratio.
569
502
  */
570
- var COURTESY_FONT_STACK = "DejaVu Sans,DejaVu Sans Mono,Helvetica,Arial,sans-serif";
571
- /** The word the frame carries. Deliberately the operator's vocabulary. */
572
- function courtesyLabel(reason) {
573
- switch (reason) {
574
- case "disabled": return "Disabled";
575
- case "offline": return "Offline";
576
- case "sleeping": return "Sleeping";
577
- }
503
+ var SNAPSHOT_WIDTH_LADDER = [
504
+ 160,
505
+ 240,
506
+ 320,
507
+ 480,
508
+ 640,
509
+ 960
510
+ ];
511
+ /** Snap UP to the next rung — never below what was asked, so the client is not
512
+ * handed an image it has to upscale. Above the ladder, the largest rung: the
513
+ * point is to stop serving the 4K original. */
514
+ function snapSnapshotWidth(requested) {
515
+ for (const rung of SNAPSHOT_WIDTH_LADDER) if (requested <= rung) return rung;
516
+ return SNAPSHOT_WIDTH_LADDER[SNAPSHOT_WIDTH_LADDER.length - 1] ?? requested;
578
517
  }
579
518
  /**
580
- * Background per reason. A disabled camera is a DELIBERATE state and must not
581
- * read as an alarm, so it is neutral grey; offline is a fault and is warmer.
519
+ * Parse the handler-relative path (`/<deviceId>.jpg?query`) into a request.
520
+ * Returns null for a malformed / nested / non-numeric id so the handler answers
521
+ * 404 without ever reaching `getMedia`.
582
522
  */
583
- function courtesyBackground(reason) {
584
- switch (reason) {
585
- case "disabled": return "#2b2b31";
586
- case "offline": return "#3a2b2b";
587
- case "sleeping": return "#232b3a";
588
- }
589
- }
590
- /** XML escaping for text placed inside an SVG `<text>` node. */
591
- function escapeCourtesyText(value) {
592
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
523
+ function parseSnapshotMediaRequest(url) {
524
+ const qIdx = url.indexOf("?");
525
+ const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
526
+ const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
527
+ const segment = rawPath.replace(/^\/+/, "");
528
+ if (segment.length === 0 || segment.includes("/")) return null;
529
+ const idPart = segment.replace(/\.jpe?g$/i, "");
530
+ if (!/^\d+$/.test(idPart)) return null;
531
+ const deviceId = Number.parseInt(idPart, 10);
532
+ if (!Number.isSafeInteger(deviceId)) return null;
533
+ const params = new URLSearchParams(query);
534
+ const rawStream = params.get("streamId");
535
+ const streamId = rawStream !== null && rawStream.length > 0 ? rawStream : void 0;
536
+ const rawForce = params.get("force");
537
+ const force = rawForce === "1" || rawForce === "true";
538
+ const rawWidth = params.get("w");
539
+ return {
540
+ deviceId,
541
+ streamId,
542
+ force,
543
+ width: rawWidth !== null && /^\d+$/.test(rawWidth) && Number.parseInt(rawWidth, 10) > 0 ? snapSnapshotWidth(Number.parseInt(rawWidth, 10)) : void 0
544
+ };
593
545
  }
594
546
  /**
595
- * The SVG the frame is rasterized from: the state large and centred, the
596
- * camera's name under it, smaller and dimmer.
547
+ * The response's entity tag.
597
548
  *
598
- * Sizes derive from the width so a 240 px grid tile and a 1920 px full-bleed
599
- * frame read the same, with a floor so a thumbnail stays legible.
549
+ * It has to identify the VARIANT, not just the frame: the same capture can now
550
+ * be served at several widths and from several streams, and a plain
551
+ * `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
552
+ * 304 for `?w=960` and render the small image at full size. The base form is
553
+ * unchanged for a plain request, so the identity
554
+ * `snapshot.getSnapshotOverview` advertises still matches the frame nobody
555
+ * asked to resize.
600
556
  */
601
- function buildCourtesySvg(spec) {
602
- const stateSize = Math.max(12, Math.round(spec.width / 12));
603
- const nameSize = Math.max(9, Math.round(spec.width / 26));
604
- const state = escapeCourtesyText(courtesyLabel(spec.reason));
605
- const name = escapeCourtesyText(spec.deviceName);
606
- const midY = spec.height / 2;
607
- return [
608
- `<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
609
- `<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
610
- `<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle">`,
611
- `<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
612
- `<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
613
- `</g></svg>`
614
- ].join("");
615
- }
616
- /** Cache key — a courtesy frame is a pure function of these four. */
617
- function courtesyCacheKey(spec) {
618
- return `${spec.reason}:${String(spec.width)}x${String(spec.height)}:${spec.deviceName}`;
557
+ function snapshotEtag(variant, capturedAt) {
558
+ const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
559
+ if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
560
+ if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
561
+ return `"${parts.join("-")}"`;
619
562
  }
620
563
  /**
621
- * Render the frame.
622
- *
623
- * Rejects on failure the caller decides what to do, exactly as `resizeJpeg`
624
- * does, so a broken courtesy path is never mistaken for a broken camera.
564
+ * Create a data-plane handler that serves per-device snapshots as JPEG images.
565
+ * `deps.getMedia` is called once per request; null → 404, throw → 500. A
566
+ * conditional GET with a matching `If-None-Match` produces a 304.
625
567
  */
626
- function renderCourtesyJpeg(spec) {
627
- return (0, sharp.default)(Buffer.from(buildCourtesySvg(spec))).jpeg({ quality: 82 }).toBuffer().then((bytes) => {
628
- if (bytes.length === 0) throw new Error("courtesy frame produced no bytes");
629
- return bytes;
630
- });
568
+ function createSnapshotMediaHandler(deps) {
569
+ return async (req, res) => {
570
+ if (req.method !== "GET" && req.method !== "HEAD") {
571
+ res.writeHead(405, { allow: "GET, HEAD" }).end();
572
+ return;
573
+ }
574
+ const parsed = parseSnapshotMediaRequest(req.url ?? "/");
575
+ if (parsed === null) {
576
+ res.writeHead(404).end();
577
+ return;
578
+ }
579
+ const inm = req.headers["if-none-match"];
580
+ if (typeof inm === "string" && !parsed.force && deps.peekFresh !== void 0) {
581
+ let peeked = null;
582
+ try {
583
+ peeked = await deps.peekFresh(parsed.deviceId, parsed.streamId);
584
+ } catch {
585
+ peeked = null;
586
+ }
587
+ if (peeked !== null) {
588
+ const peekedEtag = snapshotEtag({
589
+ deviceId: parsed.deviceId,
590
+ streamId: parsed.streamId,
591
+ width: parsed.width
592
+ }, peeked.capturedAt);
593
+ if (inm === peekedEtag) {
594
+ res.writeHead(304, {
595
+ etag: peekedEtag,
596
+ "cache-control": `private, max-age=${Math.max(0, Math.floor(peeked.maxAgeS))}`
597
+ }).end();
598
+ return;
599
+ }
600
+ }
601
+ }
602
+ let media;
603
+ try {
604
+ media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
605
+ } catch {
606
+ const body = "Internal server error";
607
+ res.writeHead(500, {
608
+ "content-type": "text/plain",
609
+ "content-length": String(Buffer.byteLength(body))
610
+ });
611
+ if (req.method === "HEAD") res.end();
612
+ else res.end(body);
613
+ return;
614
+ }
615
+ if (media === null) {
616
+ res.writeHead(404).end();
617
+ return;
618
+ }
619
+ const etag = snapshotEtag({
620
+ deviceId: parsed.deviceId,
621
+ streamId: parsed.streamId,
622
+ width: "servedWidth" in media ? media.servedWidth : parsed.width
623
+ }, media.capturedAt);
624
+ const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
625
+ if (inm === etag) {
626
+ res.writeHead(304, {
627
+ etag,
628
+ "cache-control": cacheControl
629
+ }).end();
630
+ return;
631
+ }
632
+ res.writeHead(200, {
633
+ "content-type": media.contentType,
634
+ "cache-control": cacheControl,
635
+ etag,
636
+ "content-length": String(media.bytes.byteLength)
637
+ });
638
+ if (req.method === "HEAD") res.end();
639
+ else res.end(Buffer.from(media.bytes));
640
+ };
631
641
  }
632
642
  //#endregion
633
- //#region src/builtins/snapshot/snapshot-link-url.ts
643
+ //#region src/builtins/snapshot/snapshot-resize.ts
634
644
  /**
635
- * Signed, expiring links to a CLIENT-SIZED snapshot frame.
636
- *
637
- * ## Why a link plane exists at all
645
+ * Downscaling a captured frame to a card-sized thumbnail.
638
646
  *
639
- * The authenticated `/addon/snapshot/media/<id>.jpg` plane works, and it is not
640
- * going away. What it cannot do is guarantee that a client asking for a tile
641
- * actually REACHES the server and that turned out to be the whole bug.
647
+ * Applied AFTER capture rather than during it, and that is deliberate: the two
648
+ * capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
649
+ * grab) produce a JPEG by different routes, and only one of them has an
650
+ * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
651
+ * same behaviour with one implementation.
642
652
  *
643
- * Under D93 the image URL is versioned by the frame identity, and an image
644
- * request is what signalled demand for a camera. Both halves are satisfied by
645
- * the client's own image cache: `expo-image` is URL-keyed and never
646
- * revalidates, so a URL the app painted in a previous session is served from
647
- * disk with **zero network**. Measured on the live hub, reopening the app after
648
- * two minutes idle painted 15 of 16 tiles from disk — frames **168 s old**, with
649
- * not one HTTP request, therefore no demand, therefore no capture. The
650
- * operator's report ("gli snapshot sono vecchi, devo aggiornare più volte") is
651
- * that measurement.
653
+ * ── Why sharp and not ffmpeg (2026-08-11) ─────────────────────────────────
654
+ * This used to `spawn('ffmpeg')` per resize. The snapshot wrapper is a system
655
+ * builtin, so it loads in the hub's ROOT process: sampled on the live hub,
656
+ * ~1.6 of those children were running at any instant, every one of them
657
+ * parented by the root PID.
652
658
  *
653
- * That gap used to be covered on BOTH sides by this link plane and by a
654
- * server-side keep-warm timer. The timer was removed on 2026-08-11 (operator
655
- * directive: snapshots are on-demand, always), which makes this plane the only
656
- * thing standing between a client cache and a frozen tile. It carries the whole
657
- * job now.
659
+ * Be precise about what that cost the root process, because it is easy to
660
+ * overstate. The transcode itself ran in the CHILD and was charged to ffmpeg
661
+ * (16 % + 13 % of a core in that same sample), not to the parent. What the
662
+ * parent paid was the fork, the exec, the JPEG written into one pipe and read
663
+ * back out of the other, and the base64 — real event-loop work, on the loop
664
+ * that also serves the tRPC API, but NOT the 52 ms below.
658
665
  *
659
- * A minted link breaks the loop from both ends. It is produced by an RPC —
660
- * `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
661
- * signal always lands; and it carries the capture identity the RPC just WAITED
662
- * for, rather than one a cache-only poll happened to be holding.
666
+ * Benchmarked in that container, 2560×1440 640 wide, wall-clock per resize:
663
667
  *
664
- * ## What is signed, and what is only a cache key
668
+ * ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
669
+ * sharp 4.8 ms (in-process libvips, on its own threadpool)
665
670
  *
666
- * The signature covers `"<deviceId>:<width>"` and the expiry. The width is
667
- * inside it deliberately: a leaked 240 px tile link must not be replayable as a
668
- * request for the full 4 K frame. `v` (the capture identity) is NOT signed — it
669
- * exists only to key the client's image cache, so an unchanged frame is a
670
- * byte-identical URL and costs no bytes, and a new frame is a new URL and costs
671
- * exactly one fetch.
671
+ * So this removes ~29 % of a core of container CPU and the per-resize process
672
+ * churn from the root process. It was NOT verified to be the cause of that
673
+ * process sitting at 90 % nobody has profiled it.
672
674
  *
673
- * `exp` is bucketed rather than exact. A URL that were unique per mint would
674
- * defeat the client cache completely correct for freshness, and it would make
675
- * a phone re-download every tile on every 5 s poll whether or not anything
676
- * changed. Bucketing means the URL moves when the FRAME moves, and otherwise at
677
- * most once per bucket.
675
+ * `sharp` is not a new dependency: it is already a host-external
676
+ * (`HOST_EXTERNAL_SPECIFIERS`), already resolved from the framework closure at
677
+ * runtime, and already used by the terminal frame renderer. The build preset
678
+ * keeps it out of the bundle, so this import costs nothing at pack time.
678
679
  *
679
- * Pure and side-effect-free; the addon owns the secret and the clock. Unit
680
- * tested in `__tests__/snapshot-link-url.spec.ts`.
680
+ * The cost is still one resize per (device, stream, width) per cache window —
681
+ * not one per request. What changed is what a resize COSTS.
681
682
  */
683
+ var RESIZE_TIMEOUT_MS = 1e4;
682
684
  /**
683
- * How long a minted link stays valid.
684
- *
685
- * A snapshot is a live view of the operator's home, so this is short on purpose
686
- * — the exposure of a leaked link is bounded by it. Two minutes is long enough
687
- * that a page renders, re-renders and survives a brief backgrounding on the URL
688
- * it was given, and short enough that a link pasted somewhere is dead before it
689
- * is useful. The client re-mints on every overview poll (5 s), so it never
690
- * depends on the tail of this window.
685
+ * Encode quality for a derived thumbnail. Matches what the ffmpeg path
686
+ * produced (`-q:v 5` on the mjpeg encoder) closely enough that no card visibly
687
+ * changes this migration is about COST, not about re-tuning the picture.
691
688
  */
692
- var SNAPSHOT_LINK_TTL_MS = 12e4;
689
+ var JPEG_QUALITY = 82;
693
690
  /**
694
- * Quantum the expiry is rounded UP to, so a link is stable between mints.
691
+ * Scale to `width`, preserving aspect ratio.
695
692
  *
696
- * Without it every mint produces a different `exp`, hence a different URL, hence
697
- * a full re-download of an unchanged frame on every poll. With it the URL is a
698
- * pure function of (device, width, frame, bucket) so a tile fetches when its
699
- * frame moves, and at most once more per bucket.
700
- */
701
- var SNAPSHOT_LINK_EXP_BUCKET_MS = 3e4;
702
- /** The token the signature is computed over. Width is part of the identity so a
703
- * tile link cannot be escalated into a full-frame request. */
704
- function snapshotLinkId(deviceId, width) {
705
- return `${String(deviceId)}:${width === void 0 ? "full" : String(width)}`;
706
- }
707
- /** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
708
- function snapshotLinkExpiry(nowMs, ttlMs = SNAPSHOT_LINK_TTL_MS, bucketMs = SNAPSHOT_LINK_EXP_BUCKET_MS) {
709
- return Math.ceil((nowMs + ttlMs) / bucketMs) * bucketMs;
710
- }
711
- /**
712
- * The link, as a ROOT-RELATIVE path.
693
+ * Rejects on failure or timeout. It NEVER falls back to the original silently —
694
+ * a caller that quietly served the 4K frame when the resize failed would
695
+ * reproduce exactly the bug this whole module exists to fix, and nobody would
696
+ * see it. The caller decides, and logs.
713
697
  *
714
- * Deliberately not absolute. The artifact and HA planes must mint absolute URLs
715
- * because the fetcher is a phone or a notifier backend that has no idea where
716
- * the hub is and picking that base is the `hubUrl: localhost` trap the Alexa
717
- * work paid for. Here the fetcher is a client that is already connected to the
718
- * hub and holds its own `serverUrl`, so the correct base is the one it used to
719
- * make the call. Returning a path makes it impossible to hand a client a link
720
- * pointing somewhere it cannot reach.
698
+ * It DOES upscale a source narrower than `width`, and that is deliberate. A
699
+ * `withoutEnlargement: true` was tried first it is the obvious saving, since
700
+ * upscaling pays encode cost for a blurrier, larger image. But the caller
701
+ * stamps the response ETag from the width it asked for, and its resize-FAILURE
702
+ * path already returns `width: undefined` specifically so a response can never
703
+ * "claim a width the bytes do not have". Silently returning 320 px bytes for a
704
+ * `w=640` request breaks that invariant on the SUCCESS path, where nobody is
705
+ * looking. Honest output width beats a few saved pixels; revisit only together
706
+ * with the ETag.
721
707
  */
722
- function buildSnapshotLinkUrl(input) {
723
- const base = (input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`).replace(/\/+$/, "");
724
- const id = snapshotLinkId(input.deviceId, input.width);
725
- const sig = (0, _camstack_types_node.signExpiringUrl)(input.secret, id, input.expMs);
726
- const params = new URLSearchParams();
727
- if (input.width !== void 0) params.set("w", String(input.width));
728
- if (input.capturedAt !== null) params.set("v", String(input.capturedAt));
729
- params.set("exp", String(input.expMs));
730
- params.set("sig", sig);
731
- return `${base}/${String(input.deviceId)}.jpg?${params.toString()}`;
708
+ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
709
+ const work = (0, sharp.default)(bytes).resize({ width }).jpeg({
710
+ quality: JPEG_QUALITY,
711
+ mozjpeg: false
712
+ }).toBuffer().then((out) => {
713
+ if (out.length === 0) throw new Error("snapshot resize produced no bytes");
714
+ return out;
715
+ });
716
+ let timer;
717
+ const bound = new Promise((_, reject) => {
718
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`)), timeoutMs);
719
+ });
720
+ return Promise.race([work, bound]).finally(() => {
721
+ if (timer !== void 0) clearTimeout(timer);
722
+ });
732
723
  }
733
724
  /**
734
- * Parse and VERIFY a link request in one step, so a caller cannot accidentally
735
- * use the device id before checking the signature. Null = refuse (404/403);
736
- * there is deliberately no way to distinguish "bad signature" from "expired"
737
- * from "malformed" at this boundary, so a public route cannot be probed.
725
+ * Resized frames, keyed by (device, stream, width) AND validated against the
726
+ * source frame's timestamp.
727
+ *
728
+ * The timestamp is the whole correctness argument: a variant outlives nothing.
729
+ * When the underlying frame is recaptured its `capturedAt` moves, every variant
730
+ * derived from the old one stops matching, and the next request re-derives.
731
+ * Without that check a card would keep showing a thumbnail of a frame the
732
+ * full-size view had already replaced.
738
733
  */
739
- function parseVerifiedSnapshotLink(input) {
740
- const qIdx = input.url.indexOf("?");
741
- const rawPath = qIdx === -1 ? input.url : input.url.slice(0, qIdx);
742
- const query = qIdx === -1 ? "" : input.url.slice(qIdx + 1);
743
- const segment = rawPath.replace(/^\/+/, "");
744
- if (segment.length === 0 || segment.includes("/")) return null;
745
- const idPart = segment.replace(/\.jpe?g$/i, "");
746
- if (!/^\d+$/.test(idPart)) return null;
747
- const deviceId = Number.parseInt(idPart, 10);
748
- if (!Number.isSafeInteger(deviceId) || deviceId <= 0) return null;
749
- const params = new URLSearchParams(query);
750
- const rawWidth = params.get("w");
751
- if (rawWidth !== null && !/^\d+$/.test(rawWidth)) return null;
752
- const width = rawWidth === null ? void 0 : Number.parseInt(rawWidth, 10);
753
- if (width !== void 0 && (!Number.isSafeInteger(width) || width <= 0)) return null;
754
- return (0, _camstack_types_node.verifyExpiringUrl)({
755
- secret: input.secret,
756
- id: snapshotLinkId(deviceId, width),
757
- exp: params.get("exp") ?? void 0,
758
- sig: params.get("sig") ?? void 0,
759
- nowMs: input.nowMs
760
- }) ? {
761
- deviceId,
762
- width
763
- } : null;
764
- }
734
+ var SnapshotVariantCache = class SnapshotVariantCache {
735
+ byKey = /* @__PURE__ */ new Map();
736
+ keysByDevice = /* @__PURE__ */ new Map();
737
+ static key(deviceId, streamId, width) {
738
+ return `${deviceId}:${streamId ?? "auto"}:${width}`;
739
+ }
740
+ /** The variant for this exact frame, or undefined when it is missing or was
741
+ * derived from an older capture. */
742
+ get(deviceId, streamId, width, sourceTs) {
743
+ const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
744
+ return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
745
+ }
746
+ set(deviceId, streamId, width, sourceTs, bytes) {
747
+ const key = SnapshotVariantCache.key(deviceId, streamId, width);
748
+ this.byKey.set(key, {
749
+ bytes,
750
+ sourceTs
751
+ });
752
+ const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
753
+ keys.add(key);
754
+ this.keysByDevice.set(deviceId, keys);
755
+ }
756
+ deleteDevice(deviceId) {
757
+ for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
758
+ this.keysByDevice.delete(deviceId);
759
+ }
760
+ clear() {
761
+ this.byKey.clear();
762
+ this.keysByDevice.clear();
763
+ }
764
+ };
765
765
  //#endregion
766
766
  //#region src/builtins/snapshot/snapshot.addon.ts
767
767
  /**
@@ -839,7 +839,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
839
839
  */
840
840
  captureFlight = new SingleFlight(COALESCE_MS, (outcome) => outcome.ok && outcome.image !== null);
841
841
  /** Bounds simultaneous ffmpeg keyframe grabs (the wrapper path — common case). */
842
- grabPool = new Semaphore(3);
842
+ grabPool = new Semaphore(6);
843
843
  /** Bounds simultaneous native (vendor HTTP/ONVIF) snapshot fetches. */
844
844
  nativePool = new Semaphore(6);
845
845
  /**
@@ -894,6 +894,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
894
894
  getSnapshotOverview: (input) => this.getSnapshotOverview(input),
895
895
  getSnapshotLinks: (input) => this.getSnapshotLinks(input)
896
896
  };
897
+ this.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (event) => {
898
+ const deviceId = event.data.deviceId;
899
+ if (typeof deviceId === "number") this.evictRemovedDevice(deviceId, "device-unregistered");
900
+ });
897
901
  await this.serveMediaDataPlane();
898
902
  await this.serveLinkDataPlane();
899
903
  return [{
@@ -1094,7 +1098,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1094
1098
  await this.getSnapshot({
1095
1099
  deviceId,
1096
1100
  force: false
1097
- }, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS).catch(() => null);
1101
+ }, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS, LINK_MINT_DEADLINE_MS).catch(() => null);
1098
1102
  const current = this.cache.latest(deviceId)?.ts ?? null;
1099
1103
  if (alreadyCurrent || current !== null && (before === null || current > before)) return;
1100
1104
  this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
@@ -1258,7 +1262,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1258
1262
  })]
1259
1263
  }] });
1260
1264
  }
1261
- async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
1265
+ async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY) {
1262
1266
  const { deviceId } = input;
1263
1267
  const force = input.force === true;
1264
1268
  const meta = await this.lookupDeviceMeta(deviceId);
@@ -1316,9 +1320,9 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1316
1320
  log
1317
1321
  }));
1318
1322
  flight.catch(() => void 0);
1319
- const raced = await raceForResult(flight, Math.max(decision.waitMs, minimumCaptureWaitMs));
1323
+ const raced = await raceForResult(flight, Math.min(Math.max(decision.waitMs, minimumCaptureWaitMs), maximumCaptureWaitMs));
1320
1324
  if (raced.settled) try {
1321
- const resolved = this.resolveOutcome(raced.value, deviceId, hit, log);
1325
+ const resolved = await this.resolveOutcome(raced.value, deviceId, hit, log);
1322
1326
  if (resolved !== null) return resolved;
1323
1327
  return meta?.online === false ? await this.courtesyImage(deviceId, "offline", deviceName) : null;
1324
1328
  } catch (err) {
@@ -1350,30 +1354,54 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1350
1354
  * falls back to stale cache (or null); a HARD native error with no frame
1351
1355
  * and no cache propagates.
1352
1356
  */
1353
- resolveOutcome(outcome, deviceId, hit, log) {
1357
+ async resolveOutcome(outcome, deviceId, hit, log) {
1354
1358
  if (outcome.ok) {
1355
1359
  if (outcome.image) return outcome.image;
1356
1360
  if (hit) {
1357
1361
  const ageMs = Date.now() - hit.ts;
1358
- if (ageMs > this.config.staleTtlMs) log.warn("snapshot: all live paths failed — serving stale cache", {
1359
- tags: { deviceId },
1360
- meta: { ageMs }
1361
- });
1362
+ if (ageMs > this.config.staleTtlMs) {
1363
+ if (await this.staleHitBelongsToRemovedDevice(deviceId, ageMs)) return null;
1364
+ log.warn("snapshot: all live paths failed — serving stale cache", {
1365
+ tags: { deviceId },
1366
+ meta: { ageMs }
1367
+ });
1368
+ }
1362
1369
  return hit.data;
1363
1370
  }
1364
1371
  return null;
1365
1372
  }
1366
1373
  if (hit) {
1367
1374
  const ageMs = Date.now() - hit.ts;
1368
- if (ageMs > this.config.staleTtlMs) log.warn("snapshot: native failed — serving stale cache", {
1369
- tags: { deviceId },
1370
- meta: { ageMs }
1371
- });
1375
+ if (ageMs > this.config.staleTtlMs) {
1376
+ if (await this.staleHitBelongsToRemovedDevice(deviceId, ageMs)) return null;
1377
+ log.warn("snapshot: native failed — serving stale cache", {
1378
+ tags: { deviceId },
1379
+ meta: { ageMs }
1380
+ });
1381
+ }
1372
1382
  return hit.data;
1373
1383
  }
1374
1384
  throw outcome.error;
1375
1385
  }
1376
1386
  /**
1387
+ * Reconcile backstop for a stale hit: is the frame we are about to serve the
1388
+ * property of a device that no longer exists?
1389
+ *
1390
+ * Asked ONLY past `staleTtlMs`, which is already the "something is wrong"
1391
+ * threshold — so the common path costs nothing. A `true` answer evicts and
1392
+ * the caller returns null; anything else leaves the existing
1393
+ * keep-the-UI-from-going-blank contract untouched.
1394
+ */
1395
+ async staleHitBelongsToRemovedDevice(deviceId, ageMs) {
1396
+ if (await this.deviceStillExists(deviceId) !== false) return false;
1397
+ this.evictRemovedDevice(deviceId, "stale-hit-absent");
1398
+ this.ctx.logger.debug("snapshot: refused a stale frame for a device that is gone", {
1399
+ tags: { deviceId },
1400
+ meta: { ageMs }
1401
+ });
1402
+ return true;
1403
+ }
1404
+ /**
1377
1405
  * Run the capture ladder ONCE for a device: native provider first, then the
1378
1406
  * stream-broker ffmpeg fallback. Never rejects — resolves a {@link
1379
1407
  * CaptureOutcome}. On a produced frame it populates the cache (so a
@@ -1581,9 +1609,48 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1581
1609
  };
1582
1610
  }
1583
1611
  async invalidateCache(input) {
1584
- this.cache.deleteDevice(input.deviceId);
1585
- this.variants.deleteDevice(input.deviceId);
1586
- this.captureFlight.invalidatePrefix(`${input.deviceId}:`);
1612
+ this.dropDeviceCaches(input.deviceId);
1613
+ }
1614
+ /** Forget every cached artefact of one device: frames, derived variants and
1615
+ * any settled single-flight result that would answer the next request. */
1616
+ dropDeviceCaches(deviceId) {
1617
+ this.cache.deleteDevice(deviceId);
1618
+ this.variants.deleteDevice(deviceId);
1619
+ this.captureFlight.invalidatePrefix(`${deviceId}:`);
1620
+ }
1621
+ /**
1622
+ * The device is GONE — drop its cached frames and say so.
1623
+ *
1624
+ * Distinct from `invalidateCache` (a refresh, expected and silent): this is
1625
+ * the terminal case, and a deleted camera that keeps answering with its last
1626
+ * JPEG looks to an operator exactly like a camera that was never deleted.
1627
+ */
1628
+ evictRemovedDevice(deviceId, reason) {
1629
+ this.dropDeviceCaches(deviceId);
1630
+ this.ctx.logger.info("snapshot: dropped cache for removed device", {
1631
+ tags: { deviceId },
1632
+ meta: { reason }
1633
+ });
1634
+ }
1635
+ /**
1636
+ * Does device-manager still know this id?
1637
+ *
1638
+ * `null` means the question could not be answered — a transport hiccup is
1639
+ * not a deletion (D49), and the caller keeps serving what it has. Only an
1640
+ * explicit "no such device" evicts.
1641
+ */
1642
+ async deviceStillExists(deviceId) {
1643
+ const api = this.ctx.api;
1644
+ if (!api) return null;
1645
+ try {
1646
+ return await api.deviceManager.getDevice.query({ deviceId }) ? true : false;
1647
+ } catch (err) {
1648
+ this.ctx.logger.debug("snapshot: device existence check failed — keeping cache", {
1649
+ tags: { deviceId },
1650
+ meta: { error: require_dist.errMsg(err) }
1651
+ });
1652
+ return null;
1653
+ }
1587
1654
  }
1588
1655
  /**
1589
1656
  * Sleep state from the device-state MIRROR, not from a cap round-trip.