@camstack/system 1.2.79 → 1.2.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-bindings-store.d.ts +21 -0
- package/dist/builtins/device-manager/device-manager.addon.js +23 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +23 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +557 -533
- package/dist/builtins/snapshot/index.mjs +556 -533
- package/dist/builtins/snapshot/snapshot-coalescing.d.ts +6 -1
- package/dist/builtins/snapshot/snapshot-courtesy.d.ts +36 -0
- package/dist/builtins/snapshot/snapshot-resize.d.ts +43 -11
- package/dist/builtins/snapshot/snapshot.addon.d.ts +52 -45
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-cINpngi1.js → dist-CA2mim74.js} +48 -16
- package/dist/{dist-CIIFhXqm.mjs → dist-DZOZtn-0.mjs} +48 -16
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +0 -86
|
@@ -2,12 +2,62 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
5
|
+
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
+
const require_dist = require("../../dist-CA2mim74.js");
|
|
7
7
|
let zod = require("zod");
|
|
8
8
|
let node_crypto = require("node:crypto");
|
|
9
9
|
let _camstack_types_node = require("@camstack/types/node");
|
|
10
10
|
let node_child_process = require("node:child_process");
|
|
11
|
+
let sharp = require("sharp");
|
|
12
|
+
sharp = require_chunk.__toESM(sharp);
|
|
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
|
|
11
61
|
//#region src/builtins/snapshot/snapshot-coalescing.ts
|
|
12
62
|
/**
|
|
13
63
|
* Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
|
|
@@ -203,6 +253,241 @@ function raceForResult(promise, timeoutMs) {
|
|
|
203
253
|
});
|
|
204
254
|
}
|
|
205
255
|
//#endregion
|
|
256
|
+
//#region src/builtins/snapshot/snapshot-courtesy.ts
|
|
257
|
+
/**
|
|
258
|
+
* A courtesy frame for a camera that CANNOT produce one.
|
|
259
|
+
*
|
|
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").
|
|
268
|
+
*
|
|
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.
|
|
289
|
+
*/
|
|
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
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
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.
|
|
307
|
+
*/
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The SVG the frame is rasterized from: the state large and centred, the
|
|
321
|
+
* camera's name under it, smaller and dimmer.
|
|
322
|
+
*
|
|
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.
|
|
325
|
+
*/
|
|
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}`;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
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.
|
|
350
|
+
*/
|
|
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
|
+
});
|
|
356
|
+
}
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/builtins/snapshot/snapshot-link-url.ts
|
|
359
|
+
/**
|
|
360
|
+
* Signed, expiring links to a CLIENT-SIZED snapshot frame.
|
|
361
|
+
*
|
|
362
|
+
* ## Why a link plane exists at all
|
|
363
|
+
*
|
|
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.
|
|
367
|
+
*
|
|
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.
|
|
377
|
+
*
|
|
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.
|
|
383
|
+
*
|
|
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.
|
|
388
|
+
*
|
|
389
|
+
* ## What is signed, and what is only a cache key
|
|
390
|
+
*
|
|
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.
|
|
397
|
+
*
|
|
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`.
|
|
406
|
+
*/
|
|
407
|
+
/**
|
|
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.
|
|
416
|
+
*/
|
|
417
|
+
var SNAPSHOT_LINK_TTL_MS = 12e4;
|
|
418
|
+
/**
|
|
419
|
+
* Quantum the expiry is rounded UP to, so a link is stable between mints.
|
|
420
|
+
*
|
|
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.
|
|
425
|
+
*/
|
|
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;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The link, as a ROOT-RELATIVE path.
|
|
438
|
+
*
|
|
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.
|
|
446
|
+
*/
|
|
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
|
+
}
|
|
490
|
+
//#endregion
|
|
206
491
|
//#region src/builtins/snapshot/snapshot-media-handler.ts
|
|
207
492
|
/**
|
|
208
493
|
* The widths a thumbnail may be served at.
|
|
@@ -353,427 +638,130 @@ function createSnapshotMediaHandler(deps) {
|
|
|
353
638
|
if (req.method === "HEAD") res.end();
|
|
354
639
|
else res.end(Buffer.from(media.bytes));
|
|
355
640
|
};
|
|
356
|
-
}
|
|
357
|
-
//#endregion
|
|
358
|
-
//#region src/builtins/snapshot/snapshot-cache.ts
|
|
359
|
-
/** A request with no explicit stream is its OWN entry, not a wildcard: the
|
|
360
|
-
* per-device preference decides what it captures, and conflating it with an
|
|
361
|
-
* explicit request is how the original bug read. */
|
|
362
|
-
var AUTO = "auto";
|
|
363
|
-
function keyOf$1(deviceId, streamId) {
|
|
364
|
-
return `${deviceId}:${streamId ?? AUTO}`;
|
|
365
|
-
}
|
|
366
|
-
var SnapshotCache = class {
|
|
367
|
-
byKey = /* @__PURE__ */ new Map();
|
|
368
|
-
/** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
|
|
369
|
-
keysByDevice = /* @__PURE__ */ new Map();
|
|
370
|
-
get(deviceId, streamId) {
|
|
371
|
-
return this.byKey.get(keyOf$1(deviceId, streamId));
|
|
372
|
-
}
|
|
373
|
-
set(deviceId, streamId, entry) {
|
|
374
|
-
const key = keyOf$1(deviceId, streamId);
|
|
375
|
-
this.byKey.set(key, entry);
|
|
376
|
-
const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
377
|
-
keys.add(key);
|
|
378
|
-
this.keysByDevice.set(deviceId, keys);
|
|
379
|
-
}
|
|
380
|
-
/** The newest entry for a device, whichever stream produced it. */
|
|
381
|
-
latest(deviceId) {
|
|
382
|
-
let newest;
|
|
383
|
-
for (const key of this.keysByDevice.get(deviceId) ?? []) {
|
|
384
|
-
const entry = this.byKey.get(key);
|
|
385
|
-
if (entry !== void 0 && (newest === void 0 || entry.ts > newest.ts)) newest = entry;
|
|
386
|
-
}
|
|
387
|
-
return newest;
|
|
388
|
-
}
|
|
389
|
-
/**
|
|
390
|
-
* Drop every stream of one device.
|
|
391
|
-
*
|
|
392
|
-
* Device-wide on purpose: a settings change or an operator refresh
|
|
393
|
-
* invalidates the CAMERA, and leaving a sibling entry behind would keep
|
|
394
|
-
* serving the pre-change frame from the other key.
|
|
395
|
-
*/
|
|
396
|
-
deleteDevice(deviceId) {
|
|
397
|
-
for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
|
|
398
|
-
this.keysByDevice.delete(deviceId);
|
|
399
|
-
}
|
|
400
|
-
clear() {
|
|
401
|
-
this.byKey.clear();
|
|
402
|
-
this.keysByDevice.clear();
|
|
403
|
-
}
|
|
404
|
-
};
|
|
405
|
-
//#endregion
|
|
406
|
-
//#region src/builtins/snapshot/snapshot-resize.ts
|
|
407
|
-
/**
|
|
408
|
-
* Downscaling a captured frame to a card-sized thumbnail.
|
|
409
|
-
*
|
|
410
|
-
* Applied AFTER capture rather than during it, and that is deliberate: the two
|
|
411
|
-
* capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
|
|
412
|
-
* grab) produce a JPEG by different routes, and only one of them has an
|
|
413
|
-
* ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
|
|
414
|
-
* same behaviour with one implementation.
|
|
415
|
-
*
|
|
416
|
-
* The cost is one ffmpeg run per (device, stream, width) per cache window — not
|
|
417
|
-
* one per request. A camera whose card is on screen pays it once every
|
|
418
|
-
* `snapshotMaxAgeS`.
|
|
419
|
-
*
|
|
420
|
-
* `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
|
|
421
|
-
* it), so this adds no new one.
|
|
422
|
-
*/
|
|
423
|
-
var RESIZE_TIMEOUT_MS = 1e4;
|
|
424
|
-
/**
|
|
425
|
-
* Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
|
|
426
|
-
* mjpeg requires).
|
|
427
|
-
*
|
|
428
|
-
* Rejects on a non-zero exit or a timeout. It NEVER falls back to the original
|
|
429
|
-
* silently — a caller that quietly served the 4K frame when the resize failed
|
|
430
|
-
* would reproduce exactly the bug this whole change exists to fix, and nobody
|
|
431
|
-
* would see it. The caller decides, and logs.
|
|
432
|
-
*/
|
|
433
|
-
function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
|
|
434
|
-
return new Promise((resolve, reject) => {
|
|
435
|
-
const child = (0, node_child_process.spawn)("ffmpeg", [
|
|
436
|
-
"-loglevel",
|
|
437
|
-
"error",
|
|
438
|
-
"-f",
|
|
439
|
-
"image2pipe",
|
|
440
|
-
"-i",
|
|
441
|
-
"pipe:0",
|
|
442
|
-
"-vf",
|
|
443
|
-
`scale=${String(width)}:-2:flags=bilinear`,
|
|
444
|
-
"-frames:v",
|
|
445
|
-
"1",
|
|
446
|
-
"-q:v",
|
|
447
|
-
"5",
|
|
448
|
-
"-f",
|
|
449
|
-
"image2pipe",
|
|
450
|
-
"-vcodec",
|
|
451
|
-
"mjpeg",
|
|
452
|
-
"pipe:1"
|
|
453
|
-
], { stdio: [
|
|
454
|
-
"pipe",
|
|
455
|
-
"pipe",
|
|
456
|
-
"pipe"
|
|
457
|
-
] });
|
|
458
|
-
const out = [];
|
|
459
|
-
let err = "";
|
|
460
|
-
let settled = false;
|
|
461
|
-
const timer = setTimeout(() => {
|
|
462
|
-
if (settled) return;
|
|
463
|
-
settled = true;
|
|
464
|
-
child.kill("SIGKILL");
|
|
465
|
-
reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`));
|
|
466
|
-
}, timeoutMs);
|
|
467
|
-
child.stdout.on("data", (chunk) => out.push(chunk));
|
|
468
|
-
child.stderr.on("data", (chunk) => {
|
|
469
|
-
err += chunk.toString();
|
|
470
|
-
});
|
|
471
|
-
child.on("error", (e) => {
|
|
472
|
-
if (settled) return;
|
|
473
|
-
settled = true;
|
|
474
|
-
clearTimeout(timer);
|
|
475
|
-
reject(e);
|
|
476
|
-
});
|
|
477
|
-
child.on("close", (code) => {
|
|
478
|
-
if (settled) return;
|
|
479
|
-
settled = true;
|
|
480
|
-
clearTimeout(timer);
|
|
481
|
-
const buf = Buffer.concat(out);
|
|
482
|
-
if (code !== 0 || buf.length === 0) {
|
|
483
|
-
reject(/* @__PURE__ */ new Error(`snapshot resize failed (code ${String(code)}): ${err.trim() || "no output"}`));
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
resolve(buf);
|
|
487
|
-
});
|
|
488
|
-
child.stdin.on("error", () => {});
|
|
489
|
-
child.stdin.end(bytes);
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
/**
|
|
493
|
-
* Resized frames, keyed by (device, stream, width) AND validated against the
|
|
494
|
-
* source frame's timestamp.
|
|
495
|
-
*
|
|
496
|
-
* The timestamp is the whole correctness argument: a variant outlives nothing.
|
|
497
|
-
* When the underlying frame is recaptured its `capturedAt` moves, every variant
|
|
498
|
-
* derived from the old one stops matching, and the next request re-derives.
|
|
499
|
-
* Without that check a card would keep showing a thumbnail of a frame the
|
|
500
|
-
* full-size view had already replaced.
|
|
501
|
-
*/
|
|
502
|
-
var SnapshotVariantCache = class SnapshotVariantCache {
|
|
503
|
-
byKey = /* @__PURE__ */ new Map();
|
|
504
|
-
keysByDevice = /* @__PURE__ */ new Map();
|
|
505
|
-
static key(deviceId, streamId, width) {
|
|
506
|
-
return `${deviceId}:${streamId ?? "auto"}:${width}`;
|
|
507
|
-
}
|
|
508
|
-
/** The variant for this exact frame, or undefined when it is missing or was
|
|
509
|
-
* derived from an older capture. */
|
|
510
|
-
get(deviceId, streamId, width, sourceTs) {
|
|
511
|
-
const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
|
|
512
|
-
return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
|
|
513
|
-
}
|
|
514
|
-
set(deviceId, streamId, width, sourceTs, bytes) {
|
|
515
|
-
const key = SnapshotVariantCache.key(deviceId, streamId, width);
|
|
516
|
-
this.byKey.set(key, {
|
|
517
|
-
bytes,
|
|
518
|
-
sourceTs
|
|
519
|
-
});
|
|
520
|
-
const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
521
|
-
keys.add(key);
|
|
522
|
-
this.keysByDevice.set(deviceId, keys);
|
|
523
|
-
}
|
|
524
|
-
deleteDevice(deviceId) {
|
|
525
|
-
for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
|
|
526
|
-
this.keysByDevice.delete(deviceId);
|
|
527
|
-
}
|
|
528
|
-
clear() {
|
|
529
|
-
this.byKey.clear();
|
|
530
|
-
this.keysByDevice.clear();
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
|
-
//#endregion
|
|
534
|
-
//#region src/builtins/snapshot/snapshot-keep-warm.ts
|
|
535
|
-
/**
|
|
536
|
-
* Subscription bookkeeping for the snapshot keep-warm loop.
|
|
537
|
-
*
|
|
538
|
-
* The snapshot cache has exactly one writer — a successful capture — and every
|
|
539
|
-
* capture is triggered by somebody asking. Nothing refreshes a frame on its own,
|
|
540
|
-
* so with no viewer open every thumbnail in the system is as old as the last
|
|
541
|
-
* time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
|
|
542
|
-
* captured in one 55 ms burst, all 10 minutes old and not moving.
|
|
543
|
-
*
|
|
544
|
-
* The fix is a warm loop — but a blind one would dial 16 cameras forever for
|
|
545
|
-
* nobody. This module holds the thing that makes it bounded: a warm tick only
|
|
546
|
-
* touches cameras SOMEBODY IS LOOKING AT, and "looking at" is inferred from the
|
|
547
|
-
* requests themselves. One data-plane request for a device makes it an observer
|
|
548
|
-
* for {@link WARM_WINDOW_MS}; when the last viewer closes, the loop goes quiet
|
|
549
|
-
* on its own after that window and costs exactly nothing.
|
|
550
|
-
*
|
|
551
|
-
* Pure and side-effect-free (no timer, no I/O, `now` injected) — the addon owns
|
|
552
|
-
* the timer, this owns the decision. Unit-tested in
|
|
553
|
-
* `__tests__/snapshot-keep-warm.spec.ts`.
|
|
554
|
-
*/
|
|
555
|
-
/**
|
|
556
|
-
* How often the warm loop runs. Chosen so a tile is perceptibly fresh while
|
|
557
|
-
* watched; it is also the single biggest cost lever in the design (16 cameras ×
|
|
558
|
-
* 12 ticks/min against a pool of 3 ffmpeg). A chained timer, not `setInterval` —
|
|
559
|
-
* the next tick is scheduled only after the previous one settles, so a slow
|
|
560
|
-
* fleet cannot stack ticks on top of each other.
|
|
561
|
-
*/
|
|
562
|
-
var WARM_INTERVAL_MS = 5e3;
|
|
563
|
-
/**
|
|
564
|
-
* How long one request keeps a device warm. Two minutes of inertia after the
|
|
565
|
-
* last observer goes away: long enough that flipping between screens (or a
|
|
566
|
-
* phone locking briefly) does not go cold, short enough that a closed app stops
|
|
567
|
-
* costing captures well before anyone notices.
|
|
568
|
-
*/
|
|
569
|
-
var WARM_WINDOW_MS = 12e4;
|
|
570
|
-
function keyOf(deviceId, streamId) {
|
|
571
|
-
return `${deviceId}:${streamId ?? "auto"}`;
|
|
572
|
-
}
|
|
573
|
-
/**
|
|
574
|
-
* Who is being watched right now, and at what sizes.
|
|
575
|
-
*
|
|
576
|
-
* `note` on every data-plane request; `active` on every warm tick. `active`
|
|
577
|
-
* PRUNES as it walks rather than merely filtering — the map is fed by request
|
|
578
|
-
* traffic, so a filter-only read would leave every device ever requested in
|
|
579
|
-
* memory for the life of the process.
|
|
580
|
-
*/
|
|
581
|
-
var SnapshotSubscriptions = class {
|
|
582
|
-
entries = /* @__PURE__ */ new Map();
|
|
583
|
-
/** Record that somebody asked for this (device, stream) — at `width`, when the
|
|
584
|
-
* request carried one. */
|
|
585
|
-
note(deviceId, streamId, width, nowMs) {
|
|
586
|
-
const key = keyOf(deviceId, streamId);
|
|
587
|
-
const existing = this.entries.get(key);
|
|
588
|
-
if (existing) {
|
|
589
|
-
existing.lastRequestedAt = nowMs;
|
|
590
|
-
if (width !== void 0) existing.widths.set(width, nowMs);
|
|
591
|
-
this.entries.delete(key);
|
|
592
|
-
this.entries.set(key, existing);
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
this.entries.set(key, {
|
|
596
|
-
deviceId,
|
|
597
|
-
streamId,
|
|
598
|
-
lastRequestedAt: nowMs,
|
|
599
|
-
widths: width === void 0 ? /* @__PURE__ */ new Map() : new Map([[width, nowMs]])
|
|
600
|
-
});
|
|
601
|
-
while (this.entries.size > 64) {
|
|
602
|
-
const oldest = this.entries.keys().next();
|
|
603
|
-
if (oldest.done === true) break;
|
|
604
|
-
this.entries.delete(oldest.value);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
/**
|
|
608
|
-
* Refresh the window on subscriptions that ALREADY exist. Never creates one.
|
|
609
|
-
*
|
|
610
|
-
* This is what stops the design deadlocking, and the deadlock was measured on
|
|
611
|
-
* the live hub before it existed. The client re-requests an image only when
|
|
612
|
-
* the frame identity changes, and the identity changes only because the warm
|
|
613
|
-
* loop captured — so 120 s after the last image request the subscription
|
|
614
|
-
* expired, warming stopped, the identity froze, the client never asked again,
|
|
615
|
-
* and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
|
|
616
|
-
*
|
|
617
|
-
* The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
|
|
618
|
-
* the state of these cameras" IS the statement that somebody is looking. It
|
|
619
|
-
* deliberately cannot CREATE a subscription, because that poll is app-wide —
|
|
620
|
-
* if it did, opening the app would warm every camera on the install, which is
|
|
621
|
-
* the blind poll this whole design avoids. Only a rendered surface subscribes.
|
|
622
|
-
*/
|
|
623
|
-
renew(deviceIds, nowMs) {
|
|
624
|
-
for (const deviceId of deviceIds) for (const entry of this.entries.values()) if (entry.deviceId === deviceId) entry.lastRequestedAt = nowMs;
|
|
625
|
-
}
|
|
626
|
-
/** The (device, stream) pairs still inside the warm window, dropping the rest. */
|
|
627
|
-
active(nowMs, windowMs = WARM_WINDOW_MS) {
|
|
628
|
-
const targets = [];
|
|
629
|
-
for (const [key, entry] of this.entries) {
|
|
630
|
-
if (nowMs - entry.lastRequestedAt >= windowMs) {
|
|
631
|
-
this.entries.delete(key);
|
|
632
|
-
continue;
|
|
633
|
-
}
|
|
634
|
-
for (const [width, at] of entry.widths) if (nowMs - at >= windowMs) entry.widths.delete(width);
|
|
635
|
-
targets.push({
|
|
636
|
-
deviceId: entry.deviceId,
|
|
637
|
-
streamId: entry.streamId,
|
|
638
|
-
widths: [...entry.widths.keys()].sort((a, b) => a - b)
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
return targets;
|
|
642
|
-
}
|
|
643
|
-
get size() {
|
|
644
|
-
return this.entries.size;
|
|
645
|
-
}
|
|
646
|
-
clear() {
|
|
647
|
-
this.entries.clear();
|
|
648
|
-
}
|
|
649
|
-
};
|
|
641
|
+
}
|
|
650
642
|
//#endregion
|
|
651
|
-
//#region src/builtins/snapshot/snapshot-
|
|
643
|
+
//#region src/builtins/snapshot/snapshot-resize.ts
|
|
652
644
|
/**
|
|
653
|
-
*
|
|
645
|
+
* Downscaling a captured frame to a card-sized thumbnail.
|
|
654
646
|
*
|
|
655
|
-
*
|
|
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.
|
|
656
652
|
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
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.
|
|
660
658
|
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
* not one HTTP request, therefore not one enrolment, therefore nothing warming.
|
|
668
|
-
* The operator's report ("gli snapshot sono vecchi, devo aggiornare più volte")
|
|
669
|
-
* is that measurement.
|
|
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.
|
|
670
665
|
*
|
|
671
|
-
*
|
|
672
|
-
* `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
|
|
673
|
-
* signal always lands; and it carries the capture identity the RPC just WAITED
|
|
674
|
-
* 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:
|
|
675
667
|
*
|
|
676
|
-
*
|
|
668
|
+
* ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
|
|
669
|
+
* sharp 4.8 ms (in-process libvips, on its own threadpool)
|
|
677
670
|
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
* exists only to key the client's image cache, so an unchanged frame is a
|
|
682
|
-
* byte-identical URL and costs no bytes, and a new frame is a new URL and costs
|
|
683
|
-
* 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.
|
|
684
674
|
*
|
|
685
|
-
* `
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
* 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.
|
|
690
679
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
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.
|
|
693
682
|
*/
|
|
683
|
+
var RESIZE_TIMEOUT_MS = 1e4;
|
|
694
684
|
/**
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* — the exposure of a leaked link is bounded by it. Two minutes is long enough
|
|
699
|
-
* that a page renders, re-renders and survives a brief backgrounding on the URL
|
|
700
|
-
* it was given, and short enough that a link pasted somewhere is dead before it
|
|
701
|
-
* is useful. The client re-mints on every overview poll (5 s), so it never
|
|
702
|
-
* 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.
|
|
703
688
|
*/
|
|
704
|
-
var
|
|
689
|
+
var JPEG_QUALITY = 82;
|
|
705
690
|
/**
|
|
706
|
-
*
|
|
691
|
+
* Scale to `width`, preserving aspect ratio.
|
|
707
692
|
*
|
|
708
|
-
*
|
|
709
|
-
* a
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*/
|
|
713
|
-
var SNAPSHOT_LINK_EXP_BUCKET_MS = 3e4;
|
|
714
|
-
/** The token the signature is computed over. Width is part of the identity so a
|
|
715
|
-
* tile link cannot be escalated into a full-frame request. */
|
|
716
|
-
function snapshotLinkId(deviceId, width) {
|
|
717
|
-
return `${String(deviceId)}:${width === void 0 ? "full" : String(width)}`;
|
|
718
|
-
}
|
|
719
|
-
/** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
|
|
720
|
-
function snapshotLinkExpiry(nowMs, ttlMs = SNAPSHOT_LINK_TTL_MS, bucketMs = SNAPSHOT_LINK_EXP_BUCKET_MS) {
|
|
721
|
-
return Math.ceil((nowMs + ttlMs) / bucketMs) * bucketMs;
|
|
722
|
-
}
|
|
723
|
-
/**
|
|
724
|
-
* 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.
|
|
725
697
|
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
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.
|
|
733
707
|
*/
|
|
734
|
-
function
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
+
});
|
|
744
723
|
}
|
|
745
724
|
/**
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
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.
|
|
750
733
|
*/
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
}
|
|
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
|
+
};
|
|
777
765
|
//#endregion
|
|
778
766
|
//#region src/builtins/snapshot/snapshot.addon.ts
|
|
779
767
|
/**
|
|
@@ -836,13 +824,22 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
836
824
|
* against the source frame's timestamp. */
|
|
837
825
|
variants = new SnapshotVariantCache();
|
|
838
826
|
/**
|
|
827
|
+
* Rendered courtesy frames, by (reason, size, device name).
|
|
828
|
+
*
|
|
829
|
+
* A courtesy frame is a pure function of those three, so it is rendered once
|
|
830
|
+
* and reused for as long as the process lives — a disabled camera must not
|
|
831
|
+
* cost an ffmpeg run per poll. Unbounded on purpose: the key space is
|
|
832
|
+
* (3 reasons × this node's own cameras), not user input.
|
|
833
|
+
*/
|
|
834
|
+
courtesyFrames = /* @__PURE__ */ new Map();
|
|
835
|
+
/**
|
|
839
836
|
* De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
|
|
840
837
|
* settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
|
|
841
838
|
* buttons) collapses into one capture instead of one dial per tile.
|
|
842
839
|
*/
|
|
843
840
|
captureFlight = new SingleFlight(COALESCE_MS, (outcome) => outcome.ok && outcome.image !== null);
|
|
844
841
|
/** Bounds simultaneous ffmpeg keyframe grabs (the wrapper path — common case). */
|
|
845
|
-
grabPool = new Semaphore(
|
|
842
|
+
grabPool = new Semaphore(6);
|
|
846
843
|
/** Bounds simultaneous native (vendor HTTP/ONVIF) snapshot fetches. */
|
|
847
844
|
nativePool = new Semaphore(6);
|
|
848
845
|
/**
|
|
@@ -874,17 +871,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
874
871
|
* currently holding, which is a fleet-wide blank grid after each restart. */
|
|
875
872
|
linkSecret = null;
|
|
876
873
|
/**
|
|
877
|
-
* Who is being looked at, inferred from data-plane traffic. The keep-warm
|
|
878
|
-
* loop's entire bound — see `snapshot-keep-warm.ts`.
|
|
879
|
-
*/
|
|
880
|
-
subscriptions = new SnapshotSubscriptions();
|
|
881
|
-
/** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
|
|
882
|
-
warmTimer = null;
|
|
883
|
-
/** Set while a warm tick is running so ticks can never stack. */
|
|
884
|
-
warmTickRunning = false;
|
|
885
|
-
/** Flipped on shutdown so an in-flight tick does not reschedule itself. */
|
|
886
|
-
stopped = false;
|
|
887
|
-
/**
|
|
888
874
|
* How a full frame becomes a card-sized one. Defaults to the real ffmpeg
|
|
889
875
|
* resize; injectable so the wrapper's own logic is testable without spawning a
|
|
890
876
|
* process for every fixture. `JpegResizer` was declared for exactly this and
|
|
@@ -908,9 +894,12 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
908
894
|
getSnapshotOverview: (input) => this.getSnapshotOverview(input),
|
|
909
895
|
getSnapshotLinks: (input) => this.getSnapshotLinks(input)
|
|
910
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
|
+
});
|
|
911
901
|
await this.serveMediaDataPlane();
|
|
912
902
|
await this.serveLinkDataPlane();
|
|
913
|
-
this.scheduleWarmTick();
|
|
914
903
|
return [{
|
|
915
904
|
capability: require_dist.snapshotCapability,
|
|
916
905
|
provider
|
|
@@ -1056,14 +1045,11 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1056
1045
|
*/
|
|
1057
1046
|
async getSnapshotLinks(input) {
|
|
1058
1047
|
const secret = await this.ensureLinkSecret();
|
|
1059
|
-
const
|
|
1060
|
-
const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
|
|
1048
|
+
const expMs = snapshotLinkExpiry(Date.now(), SNAPSHOT_LINK_TTL_MS);
|
|
1061
1049
|
const served = this.linkDataPlane !== null && secret !== null;
|
|
1062
1050
|
const prefix = this.linkRoutePrefix();
|
|
1063
1051
|
const sleepingByDevice = /* @__PURE__ */ new Map();
|
|
1064
1052
|
const refreshes = input.targets.map(async (target) => {
|
|
1065
|
-
const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
|
|
1066
|
-
this.subscriptions.note(target.deviceId, void 0, width, now);
|
|
1067
1053
|
const sleeping = await this.isSleepingBatteryDevice(target.deviceId);
|
|
1068
1054
|
sleepingByDevice.set(target.deviceId, sleeping);
|
|
1069
1055
|
if (!sleeping) await this.awaitFreshEnough(target.deviceId);
|
|
@@ -1112,7 +1098,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1112
1098
|
await this.getSnapshot({
|
|
1113
1099
|
deviceId,
|
|
1114
1100
|
force: false
|
|
1115
|
-
},
|
|
1101
|
+
}, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS, LINK_MINT_DEADLINE_MS).catch(() => null);
|
|
1116
1102
|
const current = this.cache.latest(deviceId)?.ts ?? null;
|
|
1117
1103
|
if (alreadyCurrent || current !== null && (before === null || current > before)) return;
|
|
1118
1104
|
this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
|
|
@@ -1143,7 +1129,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1143
1129
|
* effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
|
|
1144
1130
|
*/
|
|
1145
1131
|
async resolveSnapshotMedia(deviceId, streamId, force, width) {
|
|
1146
|
-
this.subscriptions.note(deviceId, streamId, width, Date.now());
|
|
1147
1132
|
const image = await this.getSnapshot({
|
|
1148
1133
|
deviceId,
|
|
1149
1134
|
...streamId !== void 0 ? { streamId } : {},
|
|
@@ -1189,92 +1174,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1189
1174
|
};
|
|
1190
1175
|
}
|
|
1191
1176
|
/**
|
|
1192
|
-
* Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
|
|
1193
|
-
* tick is armed only after the previous one settles, so a fleet that is slower
|
|
1194
|
-
* than the interval degrades to "as fast as it can" instead of stacking ticks
|
|
1195
|
-
* on top of each other and exhausting the ffmpeg pool.
|
|
1196
|
-
*
|
|
1197
|
-
* `unref` so the loop never holds the process open.
|
|
1198
|
-
*/
|
|
1199
|
-
scheduleWarmTick() {
|
|
1200
|
-
if (this.stopped) return;
|
|
1201
|
-
this.warmTimer = setTimeout(() => {
|
|
1202
|
-
this.runWarmTick().finally(() => {
|
|
1203
|
-
this.scheduleWarmTick();
|
|
1204
|
-
});
|
|
1205
|
-
}, WARM_INTERVAL_MS);
|
|
1206
|
-
this.warmTimer.unref?.();
|
|
1207
|
-
}
|
|
1208
|
-
/**
|
|
1209
|
-
* Refresh the cache for every camera somebody is currently looking at.
|
|
1210
|
-
*
|
|
1211
|
-
* Deliberately NOT a blind poll of the whole fleet: with nobody watching this
|
|
1212
|
-
* walks an empty list and costs nothing, which is the entire difference
|
|
1213
|
-
* between this and a background poller.
|
|
1214
|
-
*
|
|
1215
|
-
* Battery cameras need no special case here and must not get one. A warm tick
|
|
1216
|
-
* is a plain non-forced `getSnapshot`, so it meets the two gates that already
|
|
1217
|
-
* exist — the 3600 s battery max-age (which makes the tick a cache hit) and
|
|
1218
|
-
* the sleeping-battery gate (which refuses to capture without `force`). A
|
|
1219
|
-
* second battery check here would be a rule that disagrees with the authority
|
|
1220
|
-
* that already owns it.
|
|
1221
|
-
*/
|
|
1222
|
-
async runWarmTick() {
|
|
1223
|
-
if (this.warmTickRunning || this.stopped) return;
|
|
1224
|
-
this.warmTickRunning = true;
|
|
1225
|
-
const logger = this.ctx.logger;
|
|
1226
|
-
try {
|
|
1227
|
-
const targets = this.subscriptions.active(Date.now());
|
|
1228
|
-
if (targets.length === 0) return;
|
|
1229
|
-
const failed = (await Promise.allSettled(targets.map((t) => this.warmOne(t, logger)))).filter((r) => r.status === "rejected").length;
|
|
1230
|
-
logger.debug("snapshot: keep-warm tick", { meta: {
|
|
1231
|
-
watched: targets.length,
|
|
1232
|
-
failed
|
|
1233
|
-
} });
|
|
1234
|
-
} catch (err) {
|
|
1235
|
-
logger.warn("snapshot: keep-warm tick failed", { meta: { error: require_dist.errMsg(err) } });
|
|
1236
|
-
} finally {
|
|
1237
|
-
this.warmTickRunning = false;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
/**
|
|
1241
|
-
* Warm one (device, stream): refresh the frame, then pre-derive the widths
|
|
1242
|
-
* that were actually requested.
|
|
1243
|
-
*
|
|
1244
|
-
* Deriving the variants matters as much as the capture. Warming only the full
|
|
1245
|
-
* frame would leave every grid tile still paying an ffmpeg resize on the
|
|
1246
|
-
* request path — the cache would be fresh and the tile would still be slow.
|
|
1247
|
-
*/
|
|
1248
|
-
async warmOne(target, logger) {
|
|
1249
|
-
const work = this.warmOneUnbounded(target, logger);
|
|
1250
|
-
work.catch(() => void 0);
|
|
1251
|
-
if (!(await raceForResult(work, 5e3)).settled) logger.debug("snapshot: keep-warm still in flight past the tick — leaving it to land", { tags: { deviceId: target.deviceId } });
|
|
1252
|
-
}
|
|
1253
|
-
async warmOneUnbounded(target, logger) {
|
|
1254
|
-
const { deviceId, streamId, widths } = target;
|
|
1255
|
-
try {
|
|
1256
|
-
if (this.stopped) return;
|
|
1257
|
-
const image = await this.getSnapshot({
|
|
1258
|
-
deviceId,
|
|
1259
|
-
...streamId !== void 0 ? { streamId } : {},
|
|
1260
|
-
force: false
|
|
1261
|
-
}, WARM_INTERVAL_MS);
|
|
1262
|
-
if (!image) return;
|
|
1263
|
-
const capturedAt = this.cache.get(deviceId, streamId)?.ts;
|
|
1264
|
-
if (capturedAt === void 0) return;
|
|
1265
|
-
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1266
|
-
for (const width of widths) {
|
|
1267
|
-
if (this.stopped) return;
|
|
1268
|
-
await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
|
|
1269
|
-
}
|
|
1270
|
-
} catch (err) {
|
|
1271
|
-
logger.debug("snapshot: keep-warm failed for device", {
|
|
1272
|
-
tags: { deviceId },
|
|
1273
|
-
meta: { error: require_dist.errMsg(err) }
|
|
1274
|
-
});
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
/**
|
|
1278
1177
|
* The frame at a card-sized width, derived once per (device, stream, width)
|
|
1279
1178
|
* per capture.
|
|
1280
1179
|
*
|
|
@@ -1321,12 +1220,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1321
1220
|
}
|
|
1322
1221
|
}
|
|
1323
1222
|
async onShutdown() {
|
|
1324
|
-
this.stopped = true;
|
|
1325
|
-
if (this.warmTimer !== null) {
|
|
1326
|
-
clearTimeout(this.warmTimer);
|
|
1327
|
-
this.warmTimer = null;
|
|
1328
|
-
}
|
|
1329
|
-
this.subscriptions.clear();
|
|
1330
1223
|
if (this.mediaDataPlane) {
|
|
1331
1224
|
await this.mediaDataPlane.dispose().catch(() => void 0);
|
|
1332
1225
|
this.mediaDataPlane = null;
|
|
@@ -1369,7 +1262,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1369
1262
|
})]
|
|
1370
1263
|
}] });
|
|
1371
1264
|
}
|
|
1372
|
-
async getSnapshot(input,
|
|
1265
|
+
async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY) {
|
|
1373
1266
|
const { deviceId } = input;
|
|
1374
1267
|
const force = input.force === true;
|
|
1375
1268
|
const meta = await this.lookupDeviceMeta(deviceId);
|
|
@@ -1380,6 +1273,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1380
1273
|
...deviceName ? { deviceName } : {}
|
|
1381
1274
|
});
|
|
1382
1275
|
const now = Date.now();
|
|
1276
|
+
if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
|
|
1383
1277
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1384
1278
|
const rawPref = prefs.snapshotStreamId;
|
|
1385
1279
|
const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
|
|
@@ -1388,7 +1282,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1388
1282
|
meta: { stream: effectiveStreamId ?? "auto" }
|
|
1389
1283
|
});
|
|
1390
1284
|
const hit = this.cache.get(deviceId, effectiveStreamId);
|
|
1391
|
-
const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3
|
|
1285
|
+
const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
|
|
1392
1286
|
const decision = decideSnapshotServe({
|
|
1393
1287
|
now,
|
|
1394
1288
|
cachedAt: hit?.ts ?? null,
|
|
@@ -1414,7 +1308,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1414
1308
|
ageMs: hit ? now - hit.ts : null
|
|
1415
1309
|
}
|
|
1416
1310
|
});
|
|
1417
|
-
|
|
1311
|
+
if (hit) return hit.data;
|
|
1312
|
+
return await this.courtesyImage(deviceId, "sleeping", deviceName);
|
|
1418
1313
|
}
|
|
1419
1314
|
const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
|
|
1420
1315
|
const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
|
|
@@ -1425,8 +1320,18 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1425
1320
|
log
|
|
1426
1321
|
}));
|
|
1427
1322
|
flight.catch(() => void 0);
|
|
1428
|
-
const raced = await raceForResult(flight, Math.max(decision.waitMs, minimumCaptureWaitMs));
|
|
1429
|
-
if (raced.settled)
|
|
1323
|
+
const raced = await raceForResult(flight, Math.min(Math.max(decision.waitMs, minimumCaptureWaitMs), maximumCaptureWaitMs));
|
|
1324
|
+
if (raced.settled) try {
|
|
1325
|
+
const resolved = await this.resolveOutcome(raced.value, deviceId, hit, log);
|
|
1326
|
+
if (resolved !== null) return resolved;
|
|
1327
|
+
return meta?.online === false ? await this.courtesyImage(deviceId, "offline", deviceName) : null;
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
if (meta?.online === false) {
|
|
1330
|
+
const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
|
|
1331
|
+
if (courtesy !== null) return courtesy;
|
|
1332
|
+
}
|
|
1333
|
+
throw err;
|
|
1334
|
+
}
|
|
1430
1335
|
if (decision.staleFallback && hit) {
|
|
1431
1336
|
if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
|
|
1432
1337
|
tags: { deviceId },
|
|
@@ -1437,6 +1342,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1437
1342
|
});
|
|
1438
1343
|
return hit.data;
|
|
1439
1344
|
}
|
|
1345
|
+
if (meta?.online === false) {
|
|
1346
|
+
const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
|
|
1347
|
+
if (courtesy !== null) return courtesy;
|
|
1348
|
+
}
|
|
1440
1349
|
return null;
|
|
1441
1350
|
}
|
|
1442
1351
|
/**
|
|
@@ -1445,30 +1354,54 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1445
1354
|
* falls back to stale cache (or null); a HARD native error with no frame
|
|
1446
1355
|
* and no cache propagates.
|
|
1447
1356
|
*/
|
|
1448
|
-
resolveOutcome(outcome, deviceId, hit, log) {
|
|
1357
|
+
async resolveOutcome(outcome, deviceId, hit, log) {
|
|
1449
1358
|
if (outcome.ok) {
|
|
1450
1359
|
if (outcome.image) return outcome.image;
|
|
1451
1360
|
if (hit) {
|
|
1452
1361
|
const ageMs = Date.now() - hit.ts;
|
|
1453
|
-
if (ageMs > this.config.staleTtlMs)
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
+
}
|
|
1457
1369
|
return hit.data;
|
|
1458
1370
|
}
|
|
1459
1371
|
return null;
|
|
1460
1372
|
}
|
|
1461
1373
|
if (hit) {
|
|
1462
1374
|
const ageMs = Date.now() - hit.ts;
|
|
1463
|
-
if (ageMs > this.config.staleTtlMs)
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
+
}
|
|
1467
1382
|
return hit.data;
|
|
1468
1383
|
}
|
|
1469
1384
|
throw outcome.error;
|
|
1470
1385
|
}
|
|
1471
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
|
+
/**
|
|
1472
1405
|
* Run the capture ladder ONCE for a device: native provider first, then the
|
|
1473
1406
|
* stream-broker ffmpeg fallback. Never rejects — resolves a {@link
|
|
1474
1407
|
* CaptureOutcome}. On a produced frame it populates the cache (so a
|
|
@@ -1676,9 +1609,48 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1676
1609
|
};
|
|
1677
1610
|
}
|
|
1678
1611
|
async invalidateCache(input) {
|
|
1679
|
-
this.
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
+
}
|
|
1682
1654
|
}
|
|
1683
1655
|
/**
|
|
1684
1656
|
* Sleep state from the device-state MIRROR, not from a cap round-trip.
|
|
@@ -1750,7 +1722,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1750
1722
|
*/
|
|
1751
1723
|
getSnapshotOverview(input) {
|
|
1752
1724
|
const now = Date.now();
|
|
1753
|
-
this.subscriptions.renew(input.deviceIds, now);
|
|
1754
1725
|
const rows = input.deviceIds.map((deviceId) => {
|
|
1755
1726
|
const hit = this.cache.latest(deviceId);
|
|
1756
1727
|
if (!hit) return {
|
|
@@ -1839,6 +1810,56 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1839
1810
|
}] };
|
|
1840
1811
|
}
|
|
1841
1812
|
/**
|
|
1813
|
+
* The frame a camera that CANNOT capture answers with.
|
|
1814
|
+
*
|
|
1815
|
+
* Returns null only if rendering itself fails — the caller then falls through
|
|
1816
|
+
* to its previous behaviour, so a broken courtesy path degrades to the old
|
|
1817
|
+
* 404 rather than masking a real capture failure as a placeholder.
|
|
1818
|
+
*
|
|
1819
|
+
* Rendered at the full tile size; the existing resize ladder derives the
|
|
1820
|
+
* card-sized variants from it exactly as it does for a real frame, so no
|
|
1821
|
+
* downstream consumer needs to know this frame is synthetic.
|
|
1822
|
+
*/
|
|
1823
|
+
async courtesyFrame(deviceId, reason, deviceName) {
|
|
1824
|
+
const width = 640;
|
|
1825
|
+
const height = Math.round(width * 9 / 16);
|
|
1826
|
+
const key = courtesyCacheKey({
|
|
1827
|
+
deviceName,
|
|
1828
|
+
reason,
|
|
1829
|
+
width,
|
|
1830
|
+
height
|
|
1831
|
+
});
|
|
1832
|
+
const cached = this.courtesyFrames.get(key);
|
|
1833
|
+
if (cached !== void 0) return cached;
|
|
1834
|
+
try {
|
|
1835
|
+
const bytes = await renderCourtesyJpeg({
|
|
1836
|
+
deviceName,
|
|
1837
|
+
reason,
|
|
1838
|
+
width,
|
|
1839
|
+
height
|
|
1840
|
+
});
|
|
1841
|
+
this.courtesyFrames.set(key, bytes);
|
|
1842
|
+
return bytes;
|
|
1843
|
+
} catch (err) {
|
|
1844
|
+
this.ctx.logger.warn("snapshot: courtesy frame failed — falling back to no image", {
|
|
1845
|
+
tags: { deviceId },
|
|
1846
|
+
meta: {
|
|
1847
|
+
reason,
|
|
1848
|
+
error: require_dist.errMsg(err)
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
return null;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
/** A courtesy frame shaped as the cap's response, or null when unrenderable. */
|
|
1855
|
+
async courtesyImage(deviceId, reason, deviceName) {
|
|
1856
|
+
const bytes = await this.courtesyFrame(deviceId, reason, deviceName ?? `#${String(deviceId)}`);
|
|
1857
|
+
return bytes === null ? null : {
|
|
1858
|
+
base64: bytes.toString("base64"),
|
|
1859
|
+
contentType: "image/jpeg"
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1842
1863
|
* Single-trip device lookup against device-manager. Returns the
|
|
1843
1864
|
* fields the wrapper actually consults — name (logging) + battery
|
|
1844
1865
|
* flag (cache window + broker-fallback gate). Sourced from the
|
|
@@ -1858,10 +1879,13 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1858
1879
|
if (!found) return null;
|
|
1859
1880
|
const features = found.features ?? [];
|
|
1860
1881
|
const rawType = found.type;
|
|
1882
|
+
const row = found;
|
|
1861
1883
|
return {
|
|
1862
1884
|
...found.name ? { name: found.name } : {},
|
|
1863
1885
|
isBattery: features.includes(require_dist.DeviceFeature.BatteryOperated),
|
|
1864
|
-
...rawType ? { type: rawType } : {}
|
|
1886
|
+
...rawType ? { type: rawType } : {},
|
|
1887
|
+
disabled: row.disabled === true,
|
|
1888
|
+
online: row.online !== false
|
|
1865
1889
|
};
|
|
1866
1890
|
} catch (err) {
|
|
1867
1891
|
this.ctx.logger.debug("deviceManager.getDevice failed during snapshot", {
|