@camstack/system 1.2.39 → 1.2.41
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/block-package.d.ts +59 -0
- package/dist/builtins/core-blocks/block-supervisor.d.ts +86 -0
- package/dist/builtins/core-blocks/core-blocks.addon.d.ts +18 -0
- package/dist/builtins/core-blocks/core-blocks.addon.js +533 -68
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +528 -69
- package/dist/builtins/core-blocks/index.d.ts +5 -3
- package/dist/builtins/core-blocks/index.js +6 -0
- package/dist/builtins/core-blocks/index.mjs +2 -2
- package/dist/builtins/device-manager/device-manager.addon.js +14 -6
- package/dist/builtins/device-manager/device-manager.addon.mjs +14 -6
- 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 +281 -16
- package/dist/builtins/snapshot/index.mjs +282 -17
- package/dist/builtins/snapshot/snapshot-cache.d.ts +45 -0
- package/dist/builtins/snapshot/snapshot-media-handler.d.ts +32 -2
- package/dist/builtins/snapshot/snapshot-resize.d.ts +50 -0
- package/dist/builtins/snapshot/snapshot.addon.d.ts +13 -0
- 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-CFinlH7b.js → dist-BECNOXoK.js} +54 -2
- package/dist/{dist-DO76YVO2.mjs → dist-rjk6L9_P.mjs} +54 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Dt as nodePin, ct as BaseAddon, et as snapshotCapability, ft as DeviceFeature, i as BatteryStatusSchema, mt as DeviceType, rt as streamQualityLabel, st as errMsg } from "../../dist-
|
|
2
|
-
import { execFile } from "node:child_process";
|
|
1
|
+
import { Dt as nodePin, ct as BaseAddon, et as snapshotCapability, ft as DeviceFeature, i as BatteryStatusSchema, mt as DeviceType, rt as streamQualityLabel, st as errMsg } from "../../dist-rjk6L9_P.mjs";
|
|
2
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
3
|
//#region src/builtins/snapshot/snapshot-coalescing.ts
|
|
4
4
|
/**
|
|
5
5
|
* Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
|
|
@@ -197,6 +197,32 @@ function raceForResult(promise, timeoutMs) {
|
|
|
197
197
|
//#endregion
|
|
198
198
|
//#region src/builtins/snapshot/snapshot-media-handler.ts
|
|
199
199
|
/**
|
|
200
|
+
* The widths a thumbnail may be served at.
|
|
201
|
+
*
|
|
202
|
+
* A ladder, not a free integer: the width is a cache key AND an ffmpeg run, so
|
|
203
|
+
* honouring `?w=` verbatim would let any caller mint unbounded work and
|
|
204
|
+
* unbounded memory. Twelve tiles measured at 181–240 px collapse onto one
|
|
205
|
+
* variant instead of twelve.
|
|
206
|
+
*
|
|
207
|
+
* Rungs are chosen for the card sizes the viewer actually renders (180–390 px)
|
|
208
|
+
* with headroom for a 2× device pixel ratio.
|
|
209
|
+
*/
|
|
210
|
+
var SNAPSHOT_WIDTH_LADDER = [
|
|
211
|
+
160,
|
|
212
|
+
240,
|
|
213
|
+
320,
|
|
214
|
+
480,
|
|
215
|
+
640,
|
|
216
|
+
960
|
|
217
|
+
];
|
|
218
|
+
/** Snap UP to the next rung — never below what was asked, so the client is not
|
|
219
|
+
* handed an image it has to upscale. Above the ladder, the largest rung: the
|
|
220
|
+
* point is to stop serving the 4K original. */
|
|
221
|
+
function snapSnapshotWidth(requested) {
|
|
222
|
+
for (const rung of SNAPSHOT_WIDTH_LADDER) if (requested <= rung) return rung;
|
|
223
|
+
return SNAPSHOT_WIDTH_LADDER[SNAPSHOT_WIDTH_LADDER.length - 1] ?? requested;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
200
226
|
* Parse the handler-relative path (`/<deviceId>.jpg?query`) into a request.
|
|
201
227
|
* Returns null for a malformed / nested / non-numeric id so the handler answers
|
|
202
228
|
* 404 without ever reaching `getMedia`.
|
|
@@ -215,13 +241,33 @@ function parseSnapshotMediaRequest(url) {
|
|
|
215
241
|
const rawStream = params.get("streamId");
|
|
216
242
|
const streamId = rawStream !== null && rawStream.length > 0 ? rawStream : void 0;
|
|
217
243
|
const rawForce = params.get("force");
|
|
244
|
+
const force = rawForce === "1" || rawForce === "true";
|
|
245
|
+
const rawWidth = params.get("w");
|
|
218
246
|
return {
|
|
219
247
|
deviceId,
|
|
220
248
|
streamId,
|
|
221
|
-
force
|
|
249
|
+
force,
|
|
250
|
+
width: rawWidth !== null && /^\d+$/.test(rawWidth) && Number.parseInt(rawWidth, 10) > 0 ? snapSnapshotWidth(Number.parseInt(rawWidth, 10)) : void 0
|
|
222
251
|
};
|
|
223
252
|
}
|
|
224
253
|
/**
|
|
254
|
+
* The response's entity tag.
|
|
255
|
+
*
|
|
256
|
+
* It has to identify the VARIANT, not just the frame: the same capture can now
|
|
257
|
+
* be served at several widths and from several streams, and a plain
|
|
258
|
+
* `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
|
|
259
|
+
* 304 for `?w=960` and render the small image at full size. The base form is
|
|
260
|
+
* unchanged for a plain request, so the identity
|
|
261
|
+
* `snapshot.getSnapshotOverview` advertises still matches the frame nobody
|
|
262
|
+
* asked to resize.
|
|
263
|
+
*/
|
|
264
|
+
function snapshotEtag(request, capturedAt) {
|
|
265
|
+
const parts = [`${String(request.deviceId)}-${String(capturedAt)}`];
|
|
266
|
+
if (request.streamId !== void 0) parts.push(`s${request.streamId}`);
|
|
267
|
+
if (request.width !== void 0) parts.push(`w${String(request.width)}`);
|
|
268
|
+
return `"${parts.join("-")}"`;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
225
271
|
* Create a data-plane handler that serves per-device snapshots as JPEG images.
|
|
226
272
|
* `deps.getMedia` is called once per request; null → 404, throw → 500. A
|
|
227
273
|
* conditional GET with a matching `If-None-Match` produces a 304.
|
|
@@ -239,7 +285,7 @@ function createSnapshotMediaHandler(deps) {
|
|
|
239
285
|
}
|
|
240
286
|
let media;
|
|
241
287
|
try {
|
|
242
|
-
media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force);
|
|
288
|
+
media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
|
|
243
289
|
} catch {
|
|
244
290
|
const body = "Internal server error";
|
|
245
291
|
res.writeHead(500, {
|
|
@@ -254,7 +300,7 @@ function createSnapshotMediaHandler(deps) {
|
|
|
254
300
|
res.writeHead(404).end();
|
|
255
301
|
return;
|
|
256
302
|
}
|
|
257
|
-
const etag =
|
|
303
|
+
const etag = snapshotEtag(parsed, media.capturedAt);
|
|
258
304
|
const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
|
|
259
305
|
if (req.headers["if-none-match"] === etag) {
|
|
260
306
|
res.writeHead(304, {
|
|
@@ -274,6 +320,182 @@ function createSnapshotMediaHandler(deps) {
|
|
|
274
320
|
};
|
|
275
321
|
}
|
|
276
322
|
//#endregion
|
|
323
|
+
//#region src/builtins/snapshot/snapshot-cache.ts
|
|
324
|
+
/** A request with no explicit stream is its OWN entry, not a wildcard: the
|
|
325
|
+
* per-device preference decides what it captures, and conflating it with an
|
|
326
|
+
* explicit request is how the original bug read. */
|
|
327
|
+
var AUTO = "auto";
|
|
328
|
+
function keyOf(deviceId, streamId) {
|
|
329
|
+
return `${deviceId}:${streamId ?? AUTO}`;
|
|
330
|
+
}
|
|
331
|
+
var SnapshotCache = class {
|
|
332
|
+
byKey = /* @__PURE__ */ new Map();
|
|
333
|
+
/** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
|
|
334
|
+
keysByDevice = /* @__PURE__ */ new Map();
|
|
335
|
+
get(deviceId, streamId) {
|
|
336
|
+
return this.byKey.get(keyOf(deviceId, streamId));
|
|
337
|
+
}
|
|
338
|
+
set(deviceId, streamId, entry) {
|
|
339
|
+
const key = keyOf(deviceId, streamId);
|
|
340
|
+
this.byKey.set(key, entry);
|
|
341
|
+
const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
342
|
+
keys.add(key);
|
|
343
|
+
this.keysByDevice.set(deviceId, keys);
|
|
344
|
+
}
|
|
345
|
+
/** The newest entry for a device, whichever stream produced it. */
|
|
346
|
+
latest(deviceId) {
|
|
347
|
+
let newest;
|
|
348
|
+
for (const key of this.keysByDevice.get(deviceId) ?? []) {
|
|
349
|
+
const entry = this.byKey.get(key);
|
|
350
|
+
if (entry !== void 0 && (newest === void 0 || entry.ts > newest.ts)) newest = entry;
|
|
351
|
+
}
|
|
352
|
+
return newest;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Drop every stream of one device.
|
|
356
|
+
*
|
|
357
|
+
* Device-wide on purpose: a settings change or an operator refresh
|
|
358
|
+
* invalidates the CAMERA, and leaving a sibling entry behind would keep
|
|
359
|
+
* serving the pre-change frame from the other key.
|
|
360
|
+
*/
|
|
361
|
+
deleteDevice(deviceId) {
|
|
362
|
+
for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
|
|
363
|
+
this.keysByDevice.delete(deviceId);
|
|
364
|
+
}
|
|
365
|
+
clear() {
|
|
366
|
+
this.byKey.clear();
|
|
367
|
+
this.keysByDevice.clear();
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/builtins/snapshot/snapshot-resize.ts
|
|
372
|
+
/**
|
|
373
|
+
* Downscaling a captured frame to a card-sized thumbnail.
|
|
374
|
+
*
|
|
375
|
+
* Applied AFTER capture rather than during it, and that is deliberate: the two
|
|
376
|
+
* capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
|
|
377
|
+
* grab) produce a JPEG by different routes, and only one of them has an
|
|
378
|
+
* ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
|
|
379
|
+
* same behaviour with one implementation.
|
|
380
|
+
*
|
|
381
|
+
* The cost is one ffmpeg run per (device, stream, width) per cache window — not
|
|
382
|
+
* one per request. A camera whose card is on screen pays it once every
|
|
383
|
+
* `snapshotMaxAgeS`.
|
|
384
|
+
*
|
|
385
|
+
* `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
|
|
386
|
+
* it), so this adds no new one.
|
|
387
|
+
*/
|
|
388
|
+
var RESIZE_TIMEOUT_MS = 1e4;
|
|
389
|
+
/**
|
|
390
|
+
* Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
|
|
391
|
+
* mjpeg requires).
|
|
392
|
+
*
|
|
393
|
+
* Rejects on a non-zero exit or a timeout. It NEVER falls back to the original
|
|
394
|
+
* silently — a caller that quietly served the 4K frame when the resize failed
|
|
395
|
+
* would reproduce exactly the bug this whole change exists to fix, and nobody
|
|
396
|
+
* would see it. The caller decides, and logs.
|
|
397
|
+
*/
|
|
398
|
+
function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
|
|
399
|
+
return new Promise((resolve, reject) => {
|
|
400
|
+
const child = spawn("ffmpeg", [
|
|
401
|
+
"-loglevel",
|
|
402
|
+
"error",
|
|
403
|
+
"-f",
|
|
404
|
+
"image2pipe",
|
|
405
|
+
"-i",
|
|
406
|
+
"pipe:0",
|
|
407
|
+
"-vf",
|
|
408
|
+
`scale=${String(width)}:-2:flags=bilinear`,
|
|
409
|
+
"-frames:v",
|
|
410
|
+
"1",
|
|
411
|
+
"-q:v",
|
|
412
|
+
"5",
|
|
413
|
+
"-f",
|
|
414
|
+
"image2pipe",
|
|
415
|
+
"-vcodec",
|
|
416
|
+
"mjpeg",
|
|
417
|
+
"pipe:1"
|
|
418
|
+
], { stdio: [
|
|
419
|
+
"pipe",
|
|
420
|
+
"pipe",
|
|
421
|
+
"pipe"
|
|
422
|
+
] });
|
|
423
|
+
const out = [];
|
|
424
|
+
let err = "";
|
|
425
|
+
let settled = false;
|
|
426
|
+
const timer = setTimeout(() => {
|
|
427
|
+
if (settled) return;
|
|
428
|
+
settled = true;
|
|
429
|
+
child.kill("SIGKILL");
|
|
430
|
+
reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`));
|
|
431
|
+
}, timeoutMs);
|
|
432
|
+
child.stdout.on("data", (chunk) => out.push(chunk));
|
|
433
|
+
child.stderr.on("data", (chunk) => {
|
|
434
|
+
err += chunk.toString();
|
|
435
|
+
});
|
|
436
|
+
child.on("error", (e) => {
|
|
437
|
+
if (settled) return;
|
|
438
|
+
settled = true;
|
|
439
|
+
clearTimeout(timer);
|
|
440
|
+
reject(e);
|
|
441
|
+
});
|
|
442
|
+
child.on("close", (code) => {
|
|
443
|
+
if (settled) return;
|
|
444
|
+
settled = true;
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
const buf = Buffer.concat(out);
|
|
447
|
+
if (code !== 0 || buf.length === 0) {
|
|
448
|
+
reject(/* @__PURE__ */ new Error(`snapshot resize failed (code ${String(code)}): ${err.trim() || "no output"}`));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
resolve(buf);
|
|
452
|
+
});
|
|
453
|
+
child.stdin.on("error", () => {});
|
|
454
|
+
child.stdin.end(bytes);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Resized frames, keyed by (device, stream, width) AND validated against the
|
|
459
|
+
* source frame's timestamp.
|
|
460
|
+
*
|
|
461
|
+
* The timestamp is the whole correctness argument: a variant outlives nothing.
|
|
462
|
+
* When the underlying frame is recaptured its `capturedAt` moves, every variant
|
|
463
|
+
* derived from the old one stops matching, and the next request re-derives.
|
|
464
|
+
* Without that check a card would keep showing a thumbnail of a frame the
|
|
465
|
+
* full-size view had already replaced.
|
|
466
|
+
*/
|
|
467
|
+
var SnapshotVariantCache = class SnapshotVariantCache {
|
|
468
|
+
byKey = /* @__PURE__ */ new Map();
|
|
469
|
+
keysByDevice = /* @__PURE__ */ new Map();
|
|
470
|
+
static key(deviceId, streamId, width) {
|
|
471
|
+
return `${deviceId}:${streamId ?? "auto"}:${width}`;
|
|
472
|
+
}
|
|
473
|
+
/** The variant for this exact frame, or undefined when it is missing or was
|
|
474
|
+
* derived from an older capture. */
|
|
475
|
+
get(deviceId, streamId, width, sourceTs) {
|
|
476
|
+
const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
|
|
477
|
+
return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
|
|
478
|
+
}
|
|
479
|
+
set(deviceId, streamId, width, sourceTs, bytes) {
|
|
480
|
+
const key = SnapshotVariantCache.key(deviceId, streamId, width);
|
|
481
|
+
this.byKey.set(key, {
|
|
482
|
+
bytes,
|
|
483
|
+
sourceTs
|
|
484
|
+
});
|
|
485
|
+
const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
486
|
+
keys.add(key);
|
|
487
|
+
this.keysByDevice.set(deviceId, keys);
|
|
488
|
+
}
|
|
489
|
+
deleteDevice(deviceId) {
|
|
490
|
+
for (const key of this.keysByDevice.get(deviceId) ?? []) this.byKey.delete(key);
|
|
491
|
+
this.keysByDevice.delete(deviceId);
|
|
492
|
+
}
|
|
493
|
+
clear() {
|
|
494
|
+
this.byKey.clear();
|
|
495
|
+
this.keysByDevice.clear();
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
//#endregion
|
|
277
499
|
//#region src/builtins/snapshot/snapshot.addon.ts
|
|
278
500
|
/** Default cache window for non-battery cams (seconds). 10s feels live. */
|
|
279
501
|
var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
|
|
@@ -309,7 +531,10 @@ function effectiveMaxAgeS(prefs, isBattery) {
|
|
|
309
531
|
* publishes stream endpoints.
|
|
310
532
|
*/
|
|
311
533
|
var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
312
|
-
cache =
|
|
534
|
+
cache = new SnapshotCache();
|
|
535
|
+
/** Card-sized derivatives of the frames above, keyed by width and validated
|
|
536
|
+
* against the source frame's timestamp. */
|
|
537
|
+
variants = new SnapshotVariantCache();
|
|
313
538
|
/**
|
|
314
539
|
* De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
|
|
315
540
|
* settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
|
|
@@ -367,7 +592,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
367
592
|
* base64 path.
|
|
368
593
|
*/
|
|
369
594
|
async serveMediaDataPlane() {
|
|
370
|
-
const handler = createSnapshotMediaHandler({ getMedia: (deviceId, streamId, force) => this.resolveSnapshotMedia(deviceId, streamId, force) });
|
|
595
|
+
const handler = createSnapshotMediaHandler({ getMedia: (deviceId, streamId, force, width) => this.resolveSnapshotMedia(deviceId, streamId, force, width) });
|
|
371
596
|
try {
|
|
372
597
|
this.mediaDataPlane = await this.ctx.dataPlane?.serve({
|
|
373
598
|
prefix: "media",
|
|
@@ -388,29 +613,67 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
388
613
|
* the cache entry (matching the ETag `getSnapshotOverview` advertises) and the
|
|
389
614
|
* effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
|
|
390
615
|
*/
|
|
391
|
-
async resolveSnapshotMedia(deviceId, streamId, force) {
|
|
616
|
+
async resolveSnapshotMedia(deviceId, streamId, force, width) {
|
|
392
617
|
const image = await this.getSnapshot({
|
|
393
618
|
deviceId,
|
|
394
619
|
...streamId !== void 0 ? { streamId } : {},
|
|
395
620
|
force
|
|
396
621
|
});
|
|
397
622
|
if (!image) return null;
|
|
398
|
-
const capturedAt = this.cache.get(deviceId)?.ts ?? Date.now();
|
|
623
|
+
const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
|
|
399
624
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
400
625
|
const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
|
|
401
626
|
return {
|
|
402
|
-
bytes: Buffer.from(image.base64, "base64"),
|
|
627
|
+
bytes: width === void 0 ? Buffer.from(image.base64, "base64") : await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs),
|
|
403
628
|
contentType: image.contentType,
|
|
404
629
|
capturedAt,
|
|
405
630
|
maxAgeS: effectiveMaxAgeS(prefs, isBattery)
|
|
406
631
|
};
|
|
407
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* The frame at a card-sized width, derived once per (device, stream, width)
|
|
635
|
+
* per capture.
|
|
636
|
+
*
|
|
637
|
+
* A failed resize falls back to the FULL frame — a visible card beats a
|
|
638
|
+
* broken one — but never silently: the card would otherwise keep costing a
|
|
639
|
+
* megabyte with nothing anywhere saying why, which is the shape of the bug
|
|
640
|
+
* this whole change came from.
|
|
641
|
+
*/
|
|
642
|
+
async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
|
|
643
|
+
const cached = this.variants.get(deviceId, streamId, width, capturedAt);
|
|
644
|
+
if (cached !== void 0) return cached;
|
|
645
|
+
const full = Buffer.from(image.base64, "base64");
|
|
646
|
+
try {
|
|
647
|
+
const resized = await resizeJpeg(full, width);
|
|
648
|
+
this.variants.set(deviceId, streamId, width, capturedAt, resized);
|
|
649
|
+
if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
|
|
650
|
+
tags: { deviceId },
|
|
651
|
+
meta: {
|
|
652
|
+
width,
|
|
653
|
+
fullBytes: full.length,
|
|
654
|
+
thumbBytes: resized.length
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
return resized;
|
|
658
|
+
} catch (err) {
|
|
659
|
+
this.ctx.logger.warn("snapshot: resize failed — serving the full frame", {
|
|
660
|
+
tags: { deviceId },
|
|
661
|
+
meta: {
|
|
662
|
+
width,
|
|
663
|
+
bytes: full.length,
|
|
664
|
+
error: errMsg(err)
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
return full;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
408
670
|
async onShutdown() {
|
|
409
671
|
if (this.mediaDataPlane) {
|
|
410
672
|
await this.mediaDataPlane.dispose().catch(() => void 0);
|
|
411
673
|
this.mediaDataPlane = null;
|
|
412
674
|
}
|
|
413
675
|
this.cache.clear();
|
|
676
|
+
this.variants.clear();
|
|
414
677
|
this.captureFlight.clear();
|
|
415
678
|
this.ownerCache = null;
|
|
416
679
|
}
|
|
@@ -454,7 +717,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
454
717
|
...deviceName ? { deviceName } : {}
|
|
455
718
|
});
|
|
456
719
|
const now = Date.now();
|
|
457
|
-
const hit = this.cache.get(deviceId);
|
|
458
720
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
459
721
|
const rawPref = prefs.snapshotStreamId;
|
|
460
722
|
const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
|
|
@@ -462,6 +724,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
462
724
|
tags: { deviceId },
|
|
463
725
|
meta: { stream: effectiveStreamId ?? "auto" }
|
|
464
726
|
});
|
|
727
|
+
const hit = this.cache.get(deviceId, effectiveStreamId);
|
|
465
728
|
const effectiveMaxAgeMs = effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3;
|
|
466
729
|
const decision = decideSnapshotServe({
|
|
467
730
|
now,
|
|
@@ -570,7 +833,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
570
833
|
if (native) {
|
|
571
834
|
const result = await this.nativePool.run(() => native.getSnapshot(input));
|
|
572
835
|
if (result) {
|
|
573
|
-
this.cache.set(deviceId, {
|
|
836
|
+
this.cache.set(deviceId, effectiveStreamId, {
|
|
574
837
|
data: result,
|
|
575
838
|
ts: now,
|
|
576
839
|
streamId: effectiveStreamId ?? null
|
|
@@ -604,7 +867,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
604
867
|
if (!(isBatteryDevice && nativeAbsent && !await this.hasStreamingBrokerForDevice(deviceId))) try {
|
|
605
868
|
const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId);
|
|
606
869
|
if (fallback) {
|
|
607
|
-
this.cache.set(deviceId, {
|
|
870
|
+
this.cache.set(deviceId, effectiveStreamId, {
|
|
608
871
|
data: fallback,
|
|
609
872
|
ts: now,
|
|
610
873
|
streamId: effectiveStreamId ?? null
|
|
@@ -750,7 +1013,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
750
1013
|
};
|
|
751
1014
|
}
|
|
752
1015
|
async invalidateCache(input) {
|
|
753
|
-
this.cache.
|
|
1016
|
+
this.cache.deleteDevice(input.deviceId);
|
|
1017
|
+
this.variants.deleteDevice(input.deviceId);
|
|
754
1018
|
this.captureFlight.invalidatePrefix(`${input.deviceId}:`);
|
|
755
1019
|
}
|
|
756
1020
|
/**
|
|
@@ -824,7 +1088,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
824
1088
|
getSnapshotOverview(input) {
|
|
825
1089
|
const now = Date.now();
|
|
826
1090
|
const rows = input.deviceIds.map((deviceId) => {
|
|
827
|
-
const hit = this.cache.
|
|
1091
|
+
const hit = this.cache.latest(deviceId);
|
|
828
1092
|
if (!hit) return {
|
|
829
1093
|
deviceId,
|
|
830
1094
|
lastCapturedAt: null,
|
|
@@ -848,7 +1112,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
848
1112
|
* (cache miss) since the addon started.
|
|
849
1113
|
*/
|
|
850
1114
|
async getStatus(deviceId) {
|
|
851
|
-
const hit = this.cache.
|
|
1115
|
+
const hit = this.cache.latest(deviceId);
|
|
852
1116
|
if (!hit) return null;
|
|
853
1117
|
return {
|
|
854
1118
|
lastCapturedAt: hit.ts,
|
|
@@ -996,7 +1260,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
996
1260
|
else delete next["snapshotMaxAgeS"];
|
|
997
1261
|
}
|
|
998
1262
|
await this.ctx.settings.writeDeviceStore(deviceId, next);
|
|
999
|
-
this.cache.
|
|
1263
|
+
this.cache.deleteDevice(deviceId);
|
|
1264
|
+
this.variants.deleteDevice(deviceId);
|
|
1000
1265
|
this.captureFlight.invalidatePrefix(`${deviceId}:`);
|
|
1001
1266
|
return { success: true };
|
|
1002
1267
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-device snapshot cache, keyed by (device, STREAM).
|
|
3
|
+
*
|
|
4
|
+
* It used to be keyed by device alone, and `getSnapshot` served the hit before
|
|
5
|
+
* it had resolved which stream the caller asked for — so `?streamId=low`
|
|
6
|
+
* silently returned whatever was already cached. In production that meant the
|
|
7
|
+
* 4K main frame: probing a live camera with `low`, `sub` and `high` returned
|
|
8
|
+
* three byte-identical 1.2 MB responses. The parameter existed, was documented,
|
|
9
|
+
* and did nothing.
|
|
10
|
+
*
|
|
11
|
+
* Two views still need to ask a device-wide question — "when did this camera
|
|
12
|
+
* last produce a frame" (`getSnapshotOverview`, `getStatus`) — so
|
|
13
|
+
* {@link SnapshotCache.latest} answers it across streams rather than making
|
|
14
|
+
* every caller guess a key.
|
|
15
|
+
*/
|
|
16
|
+
/** The cached frame. Structurally the addon's `SnapshotImage` — declared here
|
|
17
|
+
* rather than imported so the cache does not depend on the 1,100-line addon. */
|
|
18
|
+
export interface SnapshotCacheImage {
|
|
19
|
+
readonly base64: string;
|
|
20
|
+
readonly contentType: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SnapshotCacheEntry {
|
|
23
|
+
readonly data: SnapshotCacheImage;
|
|
24
|
+
readonly ts: number;
|
|
25
|
+
/** Stream id resolved at capture time — 'high' | 'mid' | 'low' | custom | null. */
|
|
26
|
+
readonly streamId: string | null;
|
|
27
|
+
}
|
|
28
|
+
export declare class SnapshotCache {
|
|
29
|
+
private readonly byKey;
|
|
30
|
+
/** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
|
|
31
|
+
private readonly keysByDevice;
|
|
32
|
+
get(deviceId: number, streamId: string | undefined): SnapshotCacheEntry | undefined;
|
|
33
|
+
set(deviceId: number, streamId: string | undefined, entry: SnapshotCacheEntry): void;
|
|
34
|
+
/** The newest entry for a device, whichever stream produced it. */
|
|
35
|
+
latest(deviceId: number): SnapshotCacheEntry | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Drop every stream of one device.
|
|
38
|
+
*
|
|
39
|
+
* Device-wide on purpose: a settings change or an operator refresh
|
|
40
|
+
* invalidates the CAMERA, and leaving a sibling entry behind would keep
|
|
41
|
+
* serving the pre-change frame from the other key.
|
|
42
|
+
*/
|
|
43
|
+
deleteDevice(deviceId: number): void;
|
|
44
|
+
clear(): void;
|
|
45
|
+
}
|
|
@@ -16,20 +16,50 @@ export interface SnapshotMediaHandlerDeps {
|
|
|
16
16
|
* the wrapper's coalesced capture ladder; returns null when no frame can be
|
|
17
17
|
* produced (→ 404). Throwing produces an opaque 500 (caller logs).
|
|
18
18
|
*/
|
|
19
|
-
readonly getMedia: (deviceId: number, streamId: string | undefined, force: boolean) => Promise<SnapshotMedia | null>;
|
|
19
|
+
readonly getMedia: (deviceId: number, streamId: string | undefined, force: boolean, width: number | undefined) => Promise<SnapshotMedia | null>;
|
|
20
20
|
}
|
|
21
|
-
/** Parsed `/<deviceId>[.jpg][?streamId=…&force=1]` request. */
|
|
21
|
+
/** Parsed `/<deviceId>[.jpg][?streamId=…&force=1&w=320]` request. */
|
|
22
22
|
export interface SnapshotMediaRequest {
|
|
23
23
|
readonly deviceId: number;
|
|
24
24
|
readonly streamId: string | undefined;
|
|
25
25
|
readonly force: boolean;
|
|
26
|
+
/** Snapped target width in px, or undefined for the frame as captured. */
|
|
27
|
+
readonly width: number | undefined;
|
|
26
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The widths a thumbnail may be served at.
|
|
31
|
+
*
|
|
32
|
+
* A ladder, not a free integer: the width is a cache key AND an ffmpeg run, so
|
|
33
|
+
* honouring `?w=` verbatim would let any caller mint unbounded work and
|
|
34
|
+
* unbounded memory. Twelve tiles measured at 181–240 px collapse onto one
|
|
35
|
+
* variant instead of twelve.
|
|
36
|
+
*
|
|
37
|
+
* Rungs are chosen for the card sizes the viewer actually renders (180–390 px)
|
|
38
|
+
* with headroom for a 2× device pixel ratio.
|
|
39
|
+
*/
|
|
40
|
+
export declare const SNAPSHOT_WIDTH_LADDER: readonly number[];
|
|
41
|
+
/** Snap UP to the next rung — never below what was asked, so the client is not
|
|
42
|
+
* handed an image it has to upscale. Above the ladder, the largest rung: the
|
|
43
|
+
* point is to stop serving the 4K original. */
|
|
44
|
+
export declare function snapSnapshotWidth(requested: number): number;
|
|
27
45
|
/**
|
|
28
46
|
* Parse the handler-relative path (`/<deviceId>.jpg?query`) into a request.
|
|
29
47
|
* Returns null for a malformed / nested / non-numeric id so the handler answers
|
|
30
48
|
* 404 without ever reaching `getMedia`.
|
|
31
49
|
*/
|
|
32
50
|
export declare function parseSnapshotMediaRequest(url: string): SnapshotMediaRequest | null;
|
|
51
|
+
/**
|
|
52
|
+
* The response's entity tag.
|
|
53
|
+
*
|
|
54
|
+
* It has to identify the VARIANT, not just the frame: the same capture can now
|
|
55
|
+
* be served at several widths and from several streams, and a plain
|
|
56
|
+
* `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
|
|
57
|
+
* 304 for `?w=960` and render the small image at full size. The base form is
|
|
58
|
+
* unchanged for a plain request, so the identity
|
|
59
|
+
* `snapshot.getSnapshotOverview` advertises still matches the frame nobody
|
|
60
|
+
* asked to resize.
|
|
61
|
+
*/
|
|
62
|
+
export declare function snapshotEtag(request: SnapshotMediaRequest, capturedAt: number): string;
|
|
33
63
|
/**
|
|
34
64
|
* Create a data-plane handler that serves per-device snapshots as JPEG images.
|
|
35
65
|
* `deps.getMedia` is called once per request; null → 404, throw → 500. A
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downscaling a captured frame to a card-sized thumbnail.
|
|
3
|
+
*
|
|
4
|
+
* Applied AFTER capture rather than during it, and that is deliberate: the two
|
|
5
|
+
* capture paths (the vendor's native HTTP snapshot and the ffmpeg keyframe
|
|
6
|
+
* grab) produce a JPEG by different routes, and only one of them has an
|
|
7
|
+
* ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
|
|
8
|
+
* same behaviour with one implementation.
|
|
9
|
+
*
|
|
10
|
+
* The cost is one ffmpeg run per (device, stream, width) per cache window — not
|
|
11
|
+
* one per request. A camera whose card is on screen pays it once every
|
|
12
|
+
* `snapshotMaxAgeS`.
|
|
13
|
+
*
|
|
14
|
+
* `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
|
|
15
|
+
* it), so this adds no new one.
|
|
16
|
+
*/
|
|
17
|
+
/** Runs the resize. Injected so the caller is testable without a real ffmpeg. */
|
|
18
|
+
export type JpegResizer = (bytes: Buffer, width: number) => Promise<Buffer>;
|
|
19
|
+
export declare const RESIZE_TIMEOUT_MS = 10000;
|
|
20
|
+
/**
|
|
21
|
+
* Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
|
|
22
|
+
* mjpeg requires).
|
|
23
|
+
*
|
|
24
|
+
* Rejects on a non-zero exit or a timeout. It NEVER falls back to the original
|
|
25
|
+
* silently — a caller that quietly served the 4K frame when the resize failed
|
|
26
|
+
* would reproduce exactly the bug this whole change exists to fix, and nobody
|
|
27
|
+
* would see it. The caller decides, and logs.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resizeJpeg(bytes: Buffer, width: number, timeoutMs?: number): Promise<Buffer>;
|
|
30
|
+
/**
|
|
31
|
+
* Resized frames, keyed by (device, stream, width) AND validated against the
|
|
32
|
+
* source frame's timestamp.
|
|
33
|
+
*
|
|
34
|
+
* The timestamp is the whole correctness argument: a variant outlives nothing.
|
|
35
|
+
* When the underlying frame is recaptured its `capturedAt` moves, every variant
|
|
36
|
+
* derived from the old one stops matching, and the next request re-derives.
|
|
37
|
+
* Without that check a card would keep showing a thumbnail of a frame the
|
|
38
|
+
* full-size view had already replaced.
|
|
39
|
+
*/
|
|
40
|
+
export declare class SnapshotVariantCache {
|
|
41
|
+
private readonly byKey;
|
|
42
|
+
private readonly keysByDevice;
|
|
43
|
+
private static key;
|
|
44
|
+
/** The variant for this exact frame, or undefined when it is missing or was
|
|
45
|
+
* derived from an older capture. */
|
|
46
|
+
get(deviceId: number, streamId: string | undefined, width: number, sourceTs: number): Buffer | undefined;
|
|
47
|
+
set(deviceId: number, streamId: string | undefined, width: number, sourceTs: number, bytes: Buffer): void;
|
|
48
|
+
deleteDevice(deviceId: number): void;
|
|
49
|
+
clear(): void;
|
|
50
|
+
}
|
|
@@ -30,6 +30,9 @@ interface SnapshotAddonConfig {
|
|
|
30
30
|
*/
|
|
31
31
|
export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
|
|
32
32
|
private readonly cache;
|
|
33
|
+
/** Card-sized derivatives of the frames above, keyed by width and validated
|
|
34
|
+
* against the source frame's timestamp. */
|
|
35
|
+
private readonly variants;
|
|
33
36
|
/**
|
|
34
37
|
* De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
|
|
35
38
|
* settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
|
|
@@ -78,6 +81,16 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
|
|
|
78
81
|
* effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
|
|
79
82
|
*/
|
|
80
83
|
private resolveSnapshotMedia;
|
|
84
|
+
/**
|
|
85
|
+
* The frame at a card-sized width, derived once per (device, stream, width)
|
|
86
|
+
* per capture.
|
|
87
|
+
*
|
|
88
|
+
* A failed resize falls back to the FULL frame — a visible card beats a
|
|
89
|
+
* broken one — but never silently: the card would otherwise keep costing a
|
|
90
|
+
* megabyte with nothing anywhere saying why, which is the shape of the bug
|
|
91
|
+
* this whole change came from.
|
|
92
|
+
*/
|
|
93
|
+
private thumbnailBytes;
|
|
81
94
|
protected onShutdown(): Promise<void>;
|
|
82
95
|
/**
|
|
83
96
|
* Drop the cached ingest owner whenever addon-level settings change —
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-BECNOXoK.js");
|
|
7
7
|
let node_fs = require("node:fs");
|
|
8
8
|
node_fs = require_chunk.__toESM(node_fs);
|
|
9
9
|
let node_path = require("node:path");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { N as filesystemBrowseCapability, ct as BaseAddon, nt as storageProviderCapability } from "../../dist-
|
|
1
|
+
import { N as filesystemBrowseCapability, ct as BaseAddon, nt as storageProviderCapability } from "../../dist-rjk6L9_P.mjs";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path$1 from "node:path";
|
|
4
4
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-BECNOXoK.js");
|
|
7
7
|
let node_crypto = require("node:crypto");
|
|
8
8
|
let better_sqlite3 = require("better-sqlite3");
|
|
9
9
|
better_sqlite3 = require_chunk.__toESM(better_sqlite3);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as dataStoreProviderCapability, ct as BaseAddon, kt as parseJsonUnknown, l as RUNTIME_DEFAULTS, st as errMsg, vt as asJsonObject } from "../../dist-
|
|
1
|
+
import { C as dataStoreProviderCapability, ct as BaseAddon, kt as parseJsonUnknown, l as RUNTIME_DEFAULTS, st as errMsg, vt as asJsonObject } from "../../dist-rjk6L9_P.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import Database from "better-sqlite3";
|
|
4
4
|
//#region src/builtins/sqlite-storage/filter-compiler.ts
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-BECNOXoK.js");
|
|
7
7
|
let node_path = require("node:path");
|
|
8
8
|
node_path = require_chunk.__toESM(node_path);
|
|
9
9
|
let node_fs_promises = require("node:fs/promises");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as settingsStoreCapability, Ot as parseJsonObject, ct as BaseAddon, f as StorageLocationTypeSchema, tt as storageCapability } from "../../dist-
|
|
1
|
+
import { $ as settingsStoreCapability, Ot as parseJsonObject, ct as BaseAddon, f as StorageLocationTypeSchema, tt as storageCapability } from "../../dist-rjk6L9_P.mjs";
|
|
2
2
|
import * as path$1 from "node:path";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import { buildStorageLocationRegistry } from "@camstack/system";
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-BECNOXoK.js");
|
|
7
7
|
//#region src/builtins/system-config/system-config.addon.ts
|
|
8
8
|
/**
|
|
9
9
|
* Built-in `system-config` addon — Phase 4 of the settings redesign.
|