@camstack/system 1.2.262 → 1.2.264
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/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.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/camera-grid/addon.d.ts +38 -13
- package/dist/builtins/camera-grid/grid-activity-mirror.d.ts +32 -0
- package/dist/builtins/camera-grid/grid-activity.d.ts +50 -0
- package/dist/builtins/camera-grid/grid-camera-device.d.ts +11 -1
- package/dist/builtins/camera-grid/grid-canvas.d.ts +83 -0
- package/dist/builtins/camera-grid/grid-compositor.d.ts +36 -0
- package/dist/builtins/camera-grid/grid-detection-mapping.d.ts +31 -0
- package/dist/builtins/camera-grid/grid-profiles.d.ts +28 -33
- package/dist/builtins/camera-grid/grid-snapshot.d.ts +4 -4
- package/dist/builtins/camera-grid/grid-stream-descriptor.d.ts +4 -6
- package/dist/builtins/camera-grid/grid-stream-relay.d.ts +7 -11
- package/dist/builtins/camera-grid/grid-tile-feed.d.ts +38 -0
- package/dist/builtins/camera-grid/grid-tile-plan.d.ts +32 -0
- package/dist/builtins/camera-grid/index.d.ts +2 -2
- package/dist/builtins/camera-grid/index.js +593 -223
- package/dist/builtins/camera-grid/index.mjs +591 -220
- package/dist/builtins/camera-grid/silence-analysis.d.ts +7 -0
- 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-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -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 +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- 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 +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- 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-6hiXbrNY.js → dist-BhYweLVr.js} +74 -15
- package/dist/{dist-CQ7ItBIG.mjs → dist-CO2O_ddo.mjs} +61 -8
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{retired-settings-keys-CCXmaFTK.mjs → retired-settings-keys-BnnHWyfj.mjs} +1 -1
- package/dist/{retired-settings-keys-AmmGrCp1.js → retired-settings-keys-CKLI8VmE.js} +1 -1
- package/package.json +4 -1
|
@@ -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-BhYweLVr.js");
|
|
7
7
|
let zod = require("zod");
|
|
8
8
|
let _camstack_types_node = require("@camstack/types/node");
|
|
9
9
|
let node_path = require("node:path");
|
|
@@ -197,6 +197,10 @@ var GridCameraDevice = class extends require_dist.BaseDevice {
|
|
|
197
197
|
getSnapshot: async ({ deviceId }) => activeRuntime().getSnapshot(deviceId),
|
|
198
198
|
invalidateCache: async () => {}
|
|
199
199
|
});
|
|
200
|
+
this.ctx.registerNativeCap(require_dist.motionCapability, {
|
|
201
|
+
getStatus: async ({ deviceId }) => activeRuntime().motionStatus(deviceId),
|
|
202
|
+
isDetected: async ({ deviceId }) => activeRuntime().motionStatus(deviceId).detected
|
|
203
|
+
});
|
|
200
204
|
this.markOnline(true);
|
|
201
205
|
}
|
|
202
206
|
catalog() {
|
|
@@ -400,6 +404,330 @@ function normalizeGridRows(raw, mintId) {
|
|
|
400
404
|
};
|
|
401
405
|
}
|
|
402
406
|
//#endregion
|
|
407
|
+
//#region src/builtins/camera-grid/grid-activity-mirror.ts
|
|
408
|
+
/**
|
|
409
|
+
* What a grid says about ITSELF, kept from what its sources said.
|
|
410
|
+
*
|
|
411
|
+
* ## Why the grid answers at all
|
|
412
|
+
*
|
|
413
|
+
* A grid is a camera, and the viewer already knows how to light a camera: the
|
|
414
|
+
* motion ring keys off `motion.on-motion-changed` filtered by `deviceId`, the
|
|
415
|
+
* audio one off `pipeline.audio-inference-result`. So the grid does not need a
|
|
416
|
+
* new channel, a new cap or a line of app code — it needs to answer those
|
|
417
|
+
* questions about its own device id. The operator set both rules: motion is on
|
|
418
|
+
* if AT LEAST ONE source has it, and the audio level is the LOUDEST of them.
|
|
419
|
+
*
|
|
420
|
+
* It is derived from the SOURCES' events and never from the composed picture.
|
|
421
|
+
* Analysing the composite would be a second motion authority for cameras that
|
|
422
|
+
* already have one, and a decode this addon does not pay today.
|
|
423
|
+
*
|
|
424
|
+
* ## The falling edge is the whole difficulty
|
|
425
|
+
*
|
|
426
|
+
* Bus events are telemetry and may be dropped (D8/D11). A lost `detected:
|
|
427
|
+
* false` would leave a grid lit for ever, and the grid has no phase machine to
|
|
428
|
+
* produce a falling edge of its own — a real camera's comes from the pipeline
|
|
429
|
+
* runner, which a grid deliberately does not run.
|
|
430
|
+
*
|
|
431
|
+
* So a source's motion is remembered WITH ITS TIME and expires on its own. That
|
|
432
|
+
* is D49's shape: in-memory state refreshed off the event path, never a gate
|
|
433
|
+
* that re-reads something fallible in order to decide. A dropped clear costs a
|
|
434
|
+
* ring that stays lit until the expiry instead of for ever, and a subject that
|
|
435
|
+
* is genuinely still there keeps renewing it.
|
|
436
|
+
*
|
|
437
|
+
* The expiry is `MOTION_CLOSE_AFTER_MS`, imported and not re-spelled: it is the
|
|
438
|
+
* system's one answer to "how long after the last push is a camera still
|
|
439
|
+
* moving", and a second number here would be a second authority disagreeing
|
|
440
|
+
* with the first on exactly the cameras that make up the grid.
|
|
441
|
+
*/
|
|
442
|
+
/**
|
|
443
|
+
* How long an audio reading counts as current.
|
|
444
|
+
*
|
|
445
|
+
* The analyzer emits one per window per camera, so a source that has gone quiet
|
|
446
|
+
* simply stops arriving — there is no "silence" event to wait for. Without a
|
|
447
|
+
* window the loudest reading of the day would be the grid's level for ever.
|
|
448
|
+
* Chosen to span several analysis windows so an ordinary gap does not blink the
|
|
449
|
+
* level away, and to be far shorter than a person notices a stale ring.
|
|
450
|
+
*/
|
|
451
|
+
var AUDIO_FRESH_MS = 5e3;
|
|
452
|
+
var GridActivityMirror = class {
|
|
453
|
+
motion = /* @__PURE__ */ new Map();
|
|
454
|
+
audio = /* @__PURE__ */ new Map();
|
|
455
|
+
lastDetectedAt = null;
|
|
456
|
+
/** One source's motion push. `detected: false` is as important as `true`. */
|
|
457
|
+
noteMotion(sourceDeviceId, detected, atMs) {
|
|
458
|
+
this.motion.set(sourceDeviceId, {
|
|
459
|
+
detected,
|
|
460
|
+
atMs
|
|
461
|
+
});
|
|
462
|
+
if (detected) this.lastDetectedAt = Math.max(this.lastDetectedAt ?? 0, atMs);
|
|
463
|
+
}
|
|
464
|
+
/** One source's audio window. `dbfs` is the level, on the analyzer's scale. */
|
|
465
|
+
noteAudio(sourceDeviceId, dbfs, atMs) {
|
|
466
|
+
this.audio.set(sourceDeviceId, {
|
|
467
|
+
dbfs,
|
|
468
|
+
atMs
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Drop every source this grid no longer composes.
|
|
473
|
+
*
|
|
474
|
+
* A cell removed while its camera was moving would otherwise hold the grid
|
|
475
|
+
* lit until the expiry, for a picture the source is not even in.
|
|
476
|
+
*/
|
|
477
|
+
keepOnly(sourceDeviceIds) {
|
|
478
|
+
const keep = new Set(sourceDeviceIds);
|
|
479
|
+
const motionIds = Array.from(this.motion.keys());
|
|
480
|
+
const audioIds = Array.from(this.audio.keys());
|
|
481
|
+
for (const id of motionIds) if (!keep.has(id)) this.motion.delete(id);
|
|
482
|
+
for (const id of audioIds) if (!keep.has(id)) this.audio.delete(id);
|
|
483
|
+
}
|
|
484
|
+
/** At least one source moving, as of `nowMs`. */
|
|
485
|
+
motionAt(nowMs) {
|
|
486
|
+
let detected = false;
|
|
487
|
+
for (const state of this.motion.values()) {
|
|
488
|
+
if (!state.detected) continue;
|
|
489
|
+
if (nowMs - state.atMs > 15e3) continue;
|
|
490
|
+
detected = true;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
detected,
|
|
495
|
+
lastDetectedAt: this.lastDetectedAt
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The loudest CURRENT reading, or `null` when nothing fresh has been heard.
|
|
500
|
+
*
|
|
501
|
+
* Null and not `-Infinity`: "nobody has reported a level" is a different
|
|
502
|
+
* answer from "it is silent", and a number here would have the viewer draw a
|
|
503
|
+
* level for a grid whose sources are not being analysed at all.
|
|
504
|
+
*/
|
|
505
|
+
loudestAt(nowMs) {
|
|
506
|
+
let loudest = null;
|
|
507
|
+
for (const state of this.audio.values()) {
|
|
508
|
+
if (nowMs - state.atMs > AUDIO_FRESH_MS) continue;
|
|
509
|
+
if (loudest === null || state.dbfs > loudest) loudest = state.dbfs;
|
|
510
|
+
}
|
|
511
|
+
return loudest;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region src/builtins/camera-grid/grid-detection-mapping.ts
|
|
516
|
+
/**
|
|
517
|
+
* Where this detection lands on the canvas — once per cell that shows it.
|
|
518
|
+
*
|
|
519
|
+
* Empty is a real answer: the subject is in a part of the source this grid does
|
|
520
|
+
* not display, or the frame arrived with no size to measure against. Neither is
|
|
521
|
+
* a reason to guess.
|
|
522
|
+
*/
|
|
523
|
+
function mapDetectionToCanvas(input) {
|
|
524
|
+
const { width: frameW, height: frameH } = input.sourceFrame;
|
|
525
|
+
if (frameW <= 0 || frameH <= 0) return [];
|
|
526
|
+
const left = input.bbox.x / frameW;
|
|
527
|
+
const top = input.bbox.y / frameH;
|
|
528
|
+
const right = (input.bbox.x + input.bbox.width) / frameW;
|
|
529
|
+
const bottom = (input.bbox.y + input.bbox.height) / frameH;
|
|
530
|
+
const out = [];
|
|
531
|
+
for (const { source, cell } of input.cells) {
|
|
532
|
+
if (source.width <= 0 || source.height <= 0) continue;
|
|
533
|
+
const l = Math.max(left, source.x);
|
|
534
|
+
const t = Math.max(top, source.y);
|
|
535
|
+
const r = Math.min(right, source.x + source.width);
|
|
536
|
+
const b = Math.min(bottom, source.y + source.height);
|
|
537
|
+
if (r <= l || b <= t) continue;
|
|
538
|
+
const cx = (l - source.x) / source.width;
|
|
539
|
+
const cy = (t - source.y) / source.height;
|
|
540
|
+
const cw = (r - l) / source.width;
|
|
541
|
+
const ch = (b - t) / source.height;
|
|
542
|
+
out.push({
|
|
543
|
+
x: (cell.x + cx * cell.width) * input.canvas.width,
|
|
544
|
+
y: (cell.y + cy * cell.height) * input.canvas.height,
|
|
545
|
+
width: cw * cell.width * input.canvas.width,
|
|
546
|
+
height: ch * cell.height * input.canvas.height
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
552
|
+
//#region src/builtins/camera-grid/grid-activity.ts
|
|
553
|
+
/**
|
|
554
|
+
* The grid's own motion / audio / detection, forwarded from its sources.
|
|
555
|
+
*
|
|
556
|
+
* ## Why the grid speaks a camera's language instead of a new one
|
|
557
|
+
*
|
|
558
|
+
* The operator asked for the grid's tile to light up in the viewer exactly as a
|
|
559
|
+
* camera's card does, with NO app change. The app already knows how: it filters
|
|
560
|
+
* `motion.on-motion-changed` by `deviceId` for the motion ring,
|
|
561
|
+
* `pipeline.audio-inference-result` for the audio one, and `detection.result`
|
|
562
|
+
* for the detection one. So the grid answers those three questions about its
|
|
563
|
+
* own device id, in the same shapes. Nothing new is invented, and nothing in
|
|
564
|
+
* the viewer is touched.
|
|
565
|
+
*
|
|
566
|
+
* The rules are the operator's: motion if AT LEAST ONE source has it, and the
|
|
567
|
+
* LOUDEST source's level for audio.
|
|
568
|
+
*
|
|
569
|
+
* ## What speaking a camera's language costs, and why it is safe here
|
|
570
|
+
*
|
|
571
|
+
* Those categories have other listeners, and emitting on them makes the grid a
|
|
572
|
+
* motion source for the whole system. Checked, one by one, before shipping:
|
|
573
|
+
* the recorder's event capture, the HomeKit and Alexa exports and the Home
|
|
574
|
+
* Assistant export are all per-device and do nothing until an operator enables
|
|
575
|
+
* them for this camera — and if an operator DOES export a grid to HomeKit, a
|
|
576
|
+
* grid reporting motion is correct, not a surprise.
|
|
577
|
+
*
|
|
578
|
+
* The one listener with no gate of its own was `pipeline-analytics`, which
|
|
579
|
+
* would have opened a motion EPISODE per grid, duplicating the rows its sources
|
|
580
|
+
* already write. Its gate turned out to be the `pipeline-analytics` WRAPPER
|
|
581
|
+
* BINDING, so the answer is the one the operator gave: a derived camera is
|
|
582
|
+
* created with the analytics wrappers OFF (`grid-analytics-defaults.ts`).
|
|
583
|
+
* That is a binding, not a new flag, so an operator who wants a grid analysed
|
|
584
|
+
* can simply turn it back on.
|
|
585
|
+
*
|
|
586
|
+
* ## Detection carries boxes whether or not anyone wanted them
|
|
587
|
+
*
|
|
588
|
+
* `detection.result` is the same event the viewer draws BOXES from, so a grid
|
|
589
|
+
* that reports detections reports boxes. Sending the source's own pixel box
|
|
590
|
+
* would draw it against the composed canvas at confidently wrong coordinates,
|
|
591
|
+
* so every box is carried through the cell geometry first
|
|
592
|
+
* (`grid-detection-mapping.ts`) and lands where the subject actually is.
|
|
593
|
+
*/
|
|
594
|
+
/**
|
|
595
|
+
* One mirror per grid, and the emission that follows an update.
|
|
596
|
+
*
|
|
597
|
+
* Deliberately NOT one mirror per source: the question is always "is THIS grid
|
|
598
|
+
* lit", and a source shared by two grids answers it twice, differently, because
|
|
599
|
+
* the other cells differ.
|
|
600
|
+
*/
|
|
601
|
+
var GridActivityForwarder = class {
|
|
602
|
+
deps;
|
|
603
|
+
mirrors = /* @__PURE__ */ new Map();
|
|
604
|
+
constructor(deps) {
|
|
605
|
+
this.deps = deps;
|
|
606
|
+
}
|
|
607
|
+
/** A source camera's motion push. */
|
|
608
|
+
onSourceMotion(sourceDeviceId, detected, atMs) {
|
|
609
|
+
for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
|
|
610
|
+
const mirror = this.mirrorFor(grid);
|
|
611
|
+
const before = mirror.motionAt(atMs).detected;
|
|
612
|
+
mirror.noteMotion(sourceDeviceId, detected, atMs);
|
|
613
|
+
const after = mirror.motionAt(atMs);
|
|
614
|
+
if (after.detected === before) continue;
|
|
615
|
+
this.emitMotion(grid.deviceId, after, atMs);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/** A source camera's audio window. */
|
|
619
|
+
onSourceAudio(sourceDeviceId, dbfs, atMs) {
|
|
620
|
+
for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
|
|
621
|
+
const mirror = this.mirrorFor(grid);
|
|
622
|
+
mirror.noteAudio(sourceDeviceId, dbfs, atMs);
|
|
623
|
+
const loudest = mirror.loudestAt(atMs);
|
|
624
|
+
if (loudest === null) continue;
|
|
625
|
+
this.emitAudio(grid.deviceId, loudest, atMs);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** A source camera's detection frame, with its boxes in SOURCE pixels. */
|
|
629
|
+
onSourceDetections(frame) {
|
|
630
|
+
for (const grid of this.deps.gridsForSource(frame.deviceId)) {
|
|
631
|
+
const cells = grid.cellsForSource(frame.deviceId);
|
|
632
|
+
if (cells.length === 0) continue;
|
|
633
|
+
const detections = [];
|
|
634
|
+
for (const detection of frame.detections) for (const bbox of mapDetectionToCanvas({
|
|
635
|
+
bbox: detection.bbox,
|
|
636
|
+
sourceFrame: {
|
|
637
|
+
width: frame.width,
|
|
638
|
+
height: frame.height
|
|
639
|
+
},
|
|
640
|
+
canvas: grid.canvas,
|
|
641
|
+
cells
|
|
642
|
+
})) {
|
|
643
|
+
const { refinedBbox: _refined, mask: _mask, ...rest } = detection;
|
|
644
|
+
detections.push({
|
|
645
|
+
...rest,
|
|
646
|
+
bbox
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (detections.length === 0) continue;
|
|
650
|
+
this.emitDetections(grid, frame, detections);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/** The grid's own motion answer, for `motion.getStatus`. */
|
|
654
|
+
motionStatusFor(deviceId, nowMs) {
|
|
655
|
+
return this.mirrors.get(deviceId)?.motionAt(nowMs) ?? {
|
|
656
|
+
detected: false,
|
|
657
|
+
lastDetectedAt: null
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/** Drop the mirror of a grid that no longer exists. */
|
|
661
|
+
forget(deviceId) {
|
|
662
|
+
this.mirrors.delete(deviceId);
|
|
663
|
+
}
|
|
664
|
+
mirrorFor(grid) {
|
|
665
|
+
let mirror = this.mirrors.get(grid.deviceId);
|
|
666
|
+
if (mirror === void 0) {
|
|
667
|
+
mirror = new GridActivityMirror();
|
|
668
|
+
this.mirrors.set(grid.deviceId, mirror);
|
|
669
|
+
}
|
|
670
|
+
mirror.keepOnly(grid.sourceDeviceIds);
|
|
671
|
+
return mirror;
|
|
672
|
+
}
|
|
673
|
+
source(deviceId) {
|
|
674
|
+
return {
|
|
675
|
+
type: "device",
|
|
676
|
+
id: deviceId,
|
|
677
|
+
addonId: this.deps.addonId,
|
|
678
|
+
deviceId
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
emitMotion(deviceId, state, atMs) {
|
|
682
|
+
const payload = {
|
|
683
|
+
deviceId,
|
|
684
|
+
detected: state.detected,
|
|
685
|
+
timestamp: atMs,
|
|
686
|
+
source: "onboard"
|
|
687
|
+
};
|
|
688
|
+
this.deps.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.MotionOnMotionChanged, this.source(deviceId), payload));
|
|
689
|
+
this.deps.logger.debug("camera grid: motion forwarded", {
|
|
690
|
+
tags: { deviceId },
|
|
691
|
+
meta: { detected: state.detected }
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
emitAudio(deviceId, dbfs, atMs) {
|
|
695
|
+
const payload = {
|
|
696
|
+
deviceId,
|
|
697
|
+
frame: {
|
|
698
|
+
kind: "audio-window",
|
|
699
|
+
windowId: `grid-${String(deviceId)}-${String(atMs)}`,
|
|
700
|
+
deviceId,
|
|
701
|
+
timestamp: atMs,
|
|
702
|
+
startMs: atMs,
|
|
703
|
+
endMs: atMs,
|
|
704
|
+
level: {
|
|
705
|
+
rms: 0,
|
|
706
|
+
dbfs
|
|
707
|
+
},
|
|
708
|
+
detections: []
|
|
709
|
+
},
|
|
710
|
+
nodeId: "hub"
|
|
711
|
+
};
|
|
712
|
+
this.deps.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.PipelineAudioInferenceResult, this.source(deviceId), payload));
|
|
713
|
+
}
|
|
714
|
+
emitDetections(grid, sourceFrame, detections) {
|
|
715
|
+
const payload = {
|
|
716
|
+
frame: {
|
|
717
|
+
kind: "frame",
|
|
718
|
+
frameId: `grid-${String(grid.deviceId)}-${sourceFrame.frameId}`,
|
|
719
|
+
deviceId: grid.deviceId,
|
|
720
|
+
timestamp: sourceFrame.timestamp,
|
|
721
|
+
width: grid.canvas.width,
|
|
722
|
+
height: grid.canvas.height,
|
|
723
|
+
detections
|
|
724
|
+
},
|
|
725
|
+
analysisResults: []
|
|
726
|
+
};
|
|
727
|
+
this.deps.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.DetectionResult, this.source(grid.deviceId), payload));
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
//#endregion
|
|
403
731
|
//#region src/builtins/camera-grid/grid-wire-format.ts
|
|
404
732
|
/** What the relay answers with, and what the sampler must demux. */
|
|
405
733
|
var GRID_WIRE_CONTENT_TYPE = "video/x-flv";
|
|
@@ -600,82 +928,50 @@ var GridLastFrameFileStore = class {
|
|
|
600
928
|
//#endregion
|
|
601
929
|
//#region src/builtins/camera-grid/grid-profiles.ts
|
|
602
930
|
/**
|
|
603
|
-
* One id, for every grid, forever
|
|
931
|
+
* One id, for every grid, forever.
|
|
604
932
|
*
|
|
605
|
-
* D519's reason
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
933
|
+
* D519's reason is what keeps this id bare: a per-instance or newly-spelled id
|
|
934
|
+
* mints a second cam-stream and orphans whatever profile assignment the
|
|
935
|
+
* operator had made. It was `high` on the live hub before the per-profile
|
|
936
|
+
* detour and it is `high` again, so nothing an operator assigned is disturbed.
|
|
609
937
|
*/
|
|
610
938
|
var GRID_CAM_STREAM_ID = "grid";
|
|
611
|
-
/** Every profile a grid could conceivably compose, in descending quality. */
|
|
612
|
-
var GRID_PROFILES = [
|
|
613
|
-
"high",
|
|
614
|
-
"mid",
|
|
615
|
-
"low"
|
|
616
|
-
];
|
|
617
939
|
/**
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
low: .25
|
|
626
|
-
};
|
|
627
|
-
/**
|
|
628
|
-
* The smallest canvas a composition is allowed to shrink to. A grid of four
|
|
629
|
-
* cells at 40x30 is not a cheaper picture, it is no picture.
|
|
940
|
+
* The slot the single composition claims.
|
|
941
|
+
*
|
|
942
|
+
* NOT decoration. A device publishing one stream with no hint is ranked by
|
|
943
|
+
* pixel count and lands in `mid` — the slot `recordings` does not select on its
|
|
944
|
+
* own, that `recordingsLow` ignores, and that scrub never reads. The Dreame
|
|
945
|
+
* robot's single 720p relay landed exactly there and its footage was written
|
|
946
|
+
* where nothing looked.
|
|
630
947
|
*/
|
|
631
|
-
var
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
948
|
+
var GRID_OUTPUT_PROFILE = "high";
|
|
949
|
+
/** Cheapest first — the order a source is read in. */
|
|
950
|
+
var CHEAPEST_FIRST = [
|
|
951
|
+
"low",
|
|
952
|
+
"mid",
|
|
953
|
+
"high"
|
|
954
|
+
];
|
|
955
|
+
/** The cheapest stream this source serves, or `null` when it serves none. */
|
|
956
|
+
function lowestServedProfile(served) {
|
|
957
|
+
return CHEAPEST_FIRST.find((profile) => served.includes(profile)) ?? null;
|
|
636
958
|
}
|
|
637
|
-
/**
|
|
638
|
-
function
|
|
639
|
-
const
|
|
959
|
+
/** Which stream to read each source at, and who cannot be read at all. */
|
|
960
|
+
function gridSourceProfiles(input) {
|
|
961
|
+
const profileBySource = /* @__PURE__ */ new Map();
|
|
962
|
+
const missingSources = [];
|
|
963
|
+
for (const deviceId of input.sourceDeviceIds) {
|
|
964
|
+
const served = input.profilesServedBy(deviceId);
|
|
965
|
+
const chosen = served === null ? null : lowestServedProfile(served);
|
|
966
|
+
if (chosen === null) missingSources.push(deviceId);
|
|
967
|
+
else profileBySource.set(deviceId, chosen);
|
|
968
|
+
}
|
|
640
969
|
return {
|
|
641
|
-
|
|
642
|
-
|
|
970
|
+
offered: input.sourceDeviceIds.length > 0 && missingSources.length === 0,
|
|
971
|
+
profileBySource,
|
|
972
|
+
missingSources
|
|
643
973
|
};
|
|
644
974
|
}
|
|
645
|
-
function evenAtLeast(value, floor) {
|
|
646
|
-
const rounded = Math.max(floor, Math.round(value));
|
|
647
|
-
return rounded % 2 === 0 ? rounded : rounded + 1;
|
|
648
|
-
}
|
|
649
|
-
/**
|
|
650
|
-
* What this grid can honestly publish, per profile, with the refusal named.
|
|
651
|
-
*
|
|
652
|
-
* Every profile gets a row whether it is offered or not: an absent row says
|
|
653
|
-
* nothing, and the caller logging a refusal needs the reason more than the
|
|
654
|
-
* caller publishing an offer needs the offer.
|
|
655
|
-
*/
|
|
656
|
-
function offeredGridProfiles(input) {
|
|
657
|
-
const served = /* @__PURE__ */ new Map();
|
|
658
|
-
for (const deviceId of input.sourceDeviceIds) served.set(deviceId, input.profilesServedBy(deviceId));
|
|
659
|
-
return GRID_PROFILES.map((profile) => {
|
|
660
|
-
if (input.sourceDeviceIds.length === 0) return {
|
|
661
|
-
profile,
|
|
662
|
-
offered: false,
|
|
663
|
-
missingSources: []
|
|
664
|
-
};
|
|
665
|
-
const missingSources = input.sourceDeviceIds.filter((deviceId) => {
|
|
666
|
-
const profiles = served.get(deviceId);
|
|
667
|
-
return profiles === null || profiles === void 0 || !profiles.includes(profile);
|
|
668
|
-
});
|
|
669
|
-
return missingSources.length === 0 ? {
|
|
670
|
-
profile,
|
|
671
|
-
offered: true
|
|
672
|
-
} : {
|
|
673
|
-
profile,
|
|
674
|
-
offered: false,
|
|
675
|
-
missingSources
|
|
676
|
-
};
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
975
|
//#endregion
|
|
680
976
|
//#region src/builtins/camera-grid/grid-sentry-sources.ts
|
|
681
977
|
function gridSourceDial(acquired) {
|
|
@@ -694,6 +990,43 @@ function gridSourceDial(acquired) {
|
|
|
694
990
|
//#endregion
|
|
695
991
|
//#region src/builtins/camera-grid/grid-snapshot.ts
|
|
696
992
|
/**
|
|
993
|
+
* A grid's snapshot — a frame of the COMPOSITION, never a dial.
|
|
994
|
+
*
|
|
995
|
+
* ## The decision, and the two things it rejects
|
|
996
|
+
*
|
|
997
|
+
* The operator: *"Lo snapshot possiamo sempre farlo dallo stream sempre, non
|
|
998
|
+
* c'e' bisogno di comporre gli snapshots"*. So:
|
|
999
|
+
*
|
|
1000
|
+
* - **Not composed from source snapshots.** That would be a SECOND compositor,
|
|
1001
|
+
* with its own geometry, its own aspect handling and its own bugs, producing
|
|
1002
|
+
* a picture that differs from the real one in ways nobody would ever notice
|
|
1003
|
+
* until they mattered.
|
|
1004
|
+
* - **Not produced by starting the composition.** The whole of D519 is "a grid
|
|
1005
|
+
* costs nothing when nobody watches". A thumbnail that dials N cameras — and
|
|
1006
|
+
* on a battery camera, wakes one — spends the entire budget of the feature on
|
|
1007
|
+
* a 160-pixel-wide picture in a list.
|
|
1008
|
+
*
|
|
1009
|
+
* ## The three states, all answered
|
|
1010
|
+
*
|
|
1011
|
+
* | state | answer |
|
|
1012
|
+
* | --- | --- |
|
|
1013
|
+
* | a composition is running | one frame sampled out of the bytes it is already emitting |
|
|
1014
|
+
* | nothing running, a frame was kept | that frame — stale, and honest about it |
|
|
1015
|
+
* | nothing running, nothing ever kept | `null`, logged with the reason |
|
|
1016
|
+
*
|
|
1017
|
+
* `null` is the courtesy card, which is the correct picture of "this grid has
|
|
1018
|
+
* never been watched". What it must never be is a reason to dial.
|
|
1019
|
+
*
|
|
1020
|
+
* ## Why this is a NATIVE snapshot provider
|
|
1021
|
+
*
|
|
1022
|
+
* The wrapper's ladder is native-first: a native that produces a frame
|
|
1023
|
+
* short-circuits before the broker frame-grab, and the broker frame-grab is the
|
|
1024
|
+
* thing that would wake the sources. A grid with a stored frame or a live
|
|
1025
|
+
* composition therefore never reaches it. The residual — a grid that has never
|
|
1026
|
+
* once been composed — is documented in D519; it cannot wake a battery camera,
|
|
1027
|
+
* because a grid never dials one live at all (`grid-sentry-sources.ts`).
|
|
1028
|
+
*/
|
|
1029
|
+
/**
|
|
697
1030
|
* Which of the three answers applies. Pure, so the rule can be read — and
|
|
698
1031
|
* sabotaged — without a socket, a file or an ffmpeg.
|
|
699
1032
|
*/
|
|
@@ -726,10 +1059,7 @@ var GridSnapshotSource = class {
|
|
|
726
1059
|
return toImage(sampled);
|
|
727
1060
|
}
|
|
728
1061
|
if (stored !== null) return toImage(stored);
|
|
729
|
-
this.deps.logger.warn("camera grid: the composition is running but produced no frame, and none was kept", {
|
|
730
|
-
tags: { deviceId },
|
|
731
|
-
meta: { profile: live.profile }
|
|
732
|
-
});
|
|
1062
|
+
this.deps.logger.warn("camera grid: the composition is running but produced no frame, and none was kept", { tags: { deviceId } });
|
|
733
1063
|
return null;
|
|
734
1064
|
}
|
|
735
1065
|
if (answer.kind === "stored" && stored !== null) return toImage(stored);
|
|
@@ -737,7 +1067,7 @@ var GridSnapshotSource = class {
|
|
|
737
1067
|
tags: { deviceId },
|
|
738
1068
|
meta: {
|
|
739
1069
|
reason: "never-composed",
|
|
740
|
-
|
|
1070
|
+
compositions: sessions.length
|
|
741
1071
|
}
|
|
742
1072
|
});
|
|
743
1073
|
return null;
|
|
@@ -746,18 +1076,12 @@ var GridSnapshotSource = class {
|
|
|
746
1076
|
try {
|
|
747
1077
|
const jpeg = await this.deps.sampleFrame(session);
|
|
748
1078
|
if (jpeg !== null && jpeg.length > 0) return jpeg;
|
|
749
|
-
this.deps.logger.warn("camera grid: could not sample a frame from the live composition", {
|
|
750
|
-
tags: { deviceId },
|
|
751
|
-
meta: { profile: session.profile }
|
|
752
|
-
});
|
|
1079
|
+
this.deps.logger.warn("camera grid: could not sample a frame from the live composition", { tags: { deviceId } });
|
|
753
1080
|
return null;
|
|
754
1081
|
} catch (error) {
|
|
755
1082
|
this.deps.logger.warn("camera grid: could not sample a frame from the live composition", {
|
|
756
1083
|
tags: { deviceId },
|
|
757
|
-
meta: {
|
|
758
|
-
profile: session.profile,
|
|
759
|
-
error: require_dist.errMsg(error)
|
|
760
|
-
}
|
|
1084
|
+
meta: { error: require_dist.errMsg(error) }
|
|
761
1085
|
});
|
|
762
1086
|
return null;
|
|
763
1087
|
}
|
|
@@ -811,24 +1135,15 @@ var PATH_PREFIX = "/grid/";
|
|
|
811
1135
|
var PATH_SUFFIX = GRID_WIRE_PATH_SUFFIX;
|
|
812
1136
|
/** How long a reader may refuse to drain before it is cut. */
|
|
813
1137
|
var SLOW_READER_MS = 5e3;
|
|
814
|
-
|
|
815
|
-
return GRID_PROFILES.find((profile) => profile === raw) ?? null;
|
|
816
|
-
}
|
|
817
|
-
/** `/grid/<instanceId>/<profile>.flv`, or `null` when it is anything else. */
|
|
1138
|
+
/** `/grid/<instanceId>.flv`, or `null` when it is anything else. */
|
|
818
1139
|
function parseGridStreamPath(url) {
|
|
819
1140
|
const path = (url ?? "").split("?")[0] ?? "";
|
|
820
1141
|
if (!path.startsWith(PATH_PREFIX) || !path.endsWith(PATH_SUFFIX)) return null;
|
|
821
1142
|
const raw = path.slice(6, path.length - PATH_SUFFIX.length);
|
|
822
|
-
|
|
823
|
-
if (at <= 0) return null;
|
|
824
|
-
const profile = asProfile(raw.slice(at + 1));
|
|
825
|
-
if (profile === null) return null;
|
|
1143
|
+
if (raw.length === 0 || raw.includes("/")) return null;
|
|
826
1144
|
try {
|
|
827
|
-
const instanceId = decodeURIComponent(raw
|
|
828
|
-
return instanceId.length > 0 ? {
|
|
829
|
-
instanceId,
|
|
830
|
-
profile
|
|
831
|
-
} : null;
|
|
1145
|
+
const instanceId = decodeURIComponent(raw);
|
|
1146
|
+
return instanceId.length > 0 ? { instanceId } : null;
|
|
832
1147
|
} catch {
|
|
833
1148
|
return null;
|
|
834
1149
|
}
|
|
@@ -867,16 +1182,13 @@ var GridStreamRelay = class {
|
|
|
867
1182
|
streamUrl(request) {
|
|
868
1183
|
if (!this.baseUrl) throw new Error("camera-grid: the stream relay is not started");
|
|
869
1184
|
const instance = encodeURIComponent(request.instanceId);
|
|
870
|
-
return `${this.baseUrl}${PATH_PREFIX}${instance}
|
|
1185
|
+
return `${this.baseUrl}${PATH_PREFIX}${instance}${PATH_SUFFIX}`;
|
|
871
1186
|
}
|
|
872
1187
|
async serve(request, res) {
|
|
873
|
-
const { instanceId
|
|
1188
|
+
const { instanceId } = request;
|
|
874
1189
|
const session = this.deps.sessionFor(request);
|
|
875
1190
|
if (session === null) {
|
|
876
|
-
this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
|
|
877
|
-
instanceId,
|
|
878
|
-
profile
|
|
879
|
-
} });
|
|
1191
|
+
this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: { instanceId } });
|
|
880
1192
|
res.writeHead(404).end();
|
|
881
1193
|
return;
|
|
882
1194
|
}
|
|
@@ -910,7 +1222,6 @@ var GridStreamRelay = class {
|
|
|
910
1222
|
tags: { deviceId: session.deviceId },
|
|
911
1223
|
meta: {
|
|
912
1224
|
instanceId,
|
|
913
|
-
profile,
|
|
914
1225
|
stalledForMs: SLOW_READER_MS
|
|
915
1226
|
}
|
|
916
1227
|
});
|
|
@@ -923,7 +1234,6 @@ var GridStreamRelay = class {
|
|
|
923
1234
|
tags: { deviceId: session.deviceId },
|
|
924
1235
|
meta: {
|
|
925
1236
|
instanceId,
|
|
926
|
-
profile,
|
|
927
1237
|
reason
|
|
928
1238
|
}
|
|
929
1239
|
});
|
|
@@ -936,7 +1246,6 @@ var GridStreamRelay = class {
|
|
|
936
1246
|
tags: { deviceId: session.deviceId },
|
|
937
1247
|
meta: {
|
|
938
1248
|
instanceId,
|
|
939
|
-
profile,
|
|
940
1249
|
error: require_dist.errMsg(error)
|
|
941
1250
|
}
|
|
942
1251
|
});
|
|
@@ -972,13 +1281,13 @@ var GridStreamRelay = class {
|
|
|
972
1281
|
* scrub never reads. The Dreame robot's one 720p relay landed exactly there and
|
|
973
1282
|
* its footage was written nowhere anything looked.
|
|
974
1283
|
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
1284
|
+
* So the single composition claims `high` EXPLICITLY (`GRID_OUTPUT_PROFILE`).
|
|
1285
|
+
* It is also the slot this stream held before the per-profile detour, which is
|
|
1286
|
+
* what keeps an operator's existing assignment intact.
|
|
978
1287
|
*/
|
|
979
1288
|
function gridStreamDescriptor(input) {
|
|
980
1289
|
return {
|
|
981
|
-
camStreamId:
|
|
1290
|
+
camStreamId: GRID_CAM_STREAM_ID,
|
|
982
1291
|
kind: GRID_WIRE_STREAM_KIND,
|
|
983
1292
|
url: input.url,
|
|
984
1293
|
codec: "h264",
|
|
@@ -987,8 +1296,8 @@ function gridStreamDescriptor(input) {
|
|
|
987
1296
|
height: input.height
|
|
988
1297
|
},
|
|
989
1298
|
fps: input.fps,
|
|
990
|
-
label:
|
|
991
|
-
profileHint:
|
|
1299
|
+
label: "Grid composition",
|
|
1300
|
+
profileHint: GRID_OUTPUT_PROFILE,
|
|
992
1301
|
autoEligible: true
|
|
993
1302
|
};
|
|
994
1303
|
}
|
|
@@ -1421,11 +1730,14 @@ var GridStreamSession = class {
|
|
|
1421
1730
|
* for a grid must win, and a reconcile that re-asserted every pass would silently
|
|
1422
1731
|
* overrule them once a minute.
|
|
1423
1732
|
*/
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1733
|
+
/**
|
|
1734
|
+
* The list lives in `@camstack/types` now, and this is an alias rather than a
|
|
1735
|
+
* second copy. There WERE two — this one and the Terminal addon's — and they
|
|
1736
|
+
* had drifted: neither named `pipeline-analytics`, the one cap with no gate of
|
|
1737
|
+
* its own, which is what a grid forwarding its sources' motion would have used
|
|
1738
|
+
* to write a duplicate motion episode per event.
|
|
1739
|
+
*/
|
|
1740
|
+
var GRID_SILENCED_CAP_NAMES = require_dist.DERIVED_CAMERA_SILENCED_CAP_NAMES;
|
|
1429
1741
|
/**
|
|
1430
1742
|
* Resolve the addon currently providing `capName` for this device, from the
|
|
1431
1743
|
* device's OWN bindings. `listBindableCapsForDeviceType` answers for the device
|
|
@@ -1766,21 +2078,18 @@ var DEFAULTS = {
|
|
|
1766
2078
|
cameraGrids: [],
|
|
1767
2079
|
cameraGridTombstones: []
|
|
1768
2080
|
};
|
|
1769
|
-
/** One session per (grid, profile). A grid composes each profile separately. */
|
|
1770
|
-
function sessionKey(instanceId, profile) {
|
|
1771
|
-
return `${instanceId}::${profile}`;
|
|
1772
|
-
}
|
|
1773
2081
|
/**
|
|
1774
|
-
* One grid's composition
|
|
1775
|
-
*
|
|
2082
|
+
* One grid's composition — there is exactly one, keyed by the grid itself.
|
|
2083
|
+
*
|
|
2084
|
+
* It used to be one per profile, and the key carried the profile. That shape
|
|
2085
|
+
* is what made a viewer's adaptive step tear a composition down and build
|
|
2086
|
+
* another; see `grid-profiles.ts` for the log that showed it.
|
|
1776
2087
|
*/
|
|
1777
2088
|
var GridSessionEntry = class {
|
|
1778
2089
|
instanceId;
|
|
1779
|
-
profile;
|
|
1780
2090
|
session;
|
|
1781
|
-
constructor(instanceId,
|
|
2091
|
+
constructor(instanceId, session) {
|
|
1782
2092
|
this.instanceId = instanceId;
|
|
1783
|
-
this.profile = profile;
|
|
1784
2093
|
this.session = session;
|
|
1785
2094
|
}
|
|
1786
2095
|
isRunning() {
|
|
@@ -1790,7 +2099,7 @@ var GridSessionEntry = class {
|
|
|
1790
2099
|
var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
1791
2100
|
relay = null;
|
|
1792
2101
|
reconcileTimer = null;
|
|
1793
|
-
/** Keyed by
|
|
2102
|
+
/** Keyed by instance id — ONE composition per grid. */
|
|
1794
2103
|
sessions = /* @__PURE__ */ new Map();
|
|
1795
2104
|
deviceIdByInstanceId = /* @__PURE__ */ new Map();
|
|
1796
2105
|
tombstones = /* @__PURE__ */ new Set();
|
|
@@ -1804,7 +2113,8 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
1804
2113
|
* (D224), and 60 s is the window for a fact — "does 615 have a low assigned"
|
|
1805
2114
|
* — that only changes when an operator changes it.
|
|
1806
2115
|
*/
|
|
1807
|
-
|
|
2116
|
+
planByInstanceId = /* @__PURE__ */ new Map();
|
|
2117
|
+
activity = null;
|
|
1808
2118
|
snapshots = null;
|
|
1809
2119
|
frameSampler = null;
|
|
1810
2120
|
ffmpegBinaryPath = "ffmpeg";
|
|
@@ -1833,11 +2143,29 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
1833
2143
|
sampleFrame: async (session) => this.sampleFrame(session),
|
|
1834
2144
|
store: new GridLastFrameFileStore({ dataDir: this.ctx.dataDir })
|
|
1835
2145
|
});
|
|
2146
|
+
this.activity = new GridActivityForwarder({
|
|
2147
|
+
eventBus: this.ctx.eventBus,
|
|
2148
|
+
logger: this.ctx.logger.child("activity"),
|
|
2149
|
+
addonId: this.ctx.id,
|
|
2150
|
+
gridsForSource: (sourceDeviceId) => this.gridsForSource(sourceDeviceId),
|
|
2151
|
+
now: () => Date.now()
|
|
2152
|
+
});
|
|
2153
|
+
this.subscribeSourceActivity();
|
|
1836
2154
|
installGridCameraRuntime({
|
|
1837
2155
|
descriptorsFor: (instanceId) => this.descriptorsFor(instanceId),
|
|
1838
2156
|
getLayout: (deviceId) => this.gridViewFor(deviceId),
|
|
1839
2157
|
saveLayout: async (patch) => this.saveGridLayout(patch),
|
|
1840
|
-
getSnapshot: async (deviceId) => this.getSnapshot(deviceId)
|
|
2158
|
+
getSnapshot: async (deviceId) => this.getSnapshot(deviceId),
|
|
2159
|
+
motionStatus: (deviceId) => {
|
|
2160
|
+
const state = this.activity?.motionStatusFor(deviceId, Date.now()) ?? {
|
|
2161
|
+
detected: false,
|
|
2162
|
+
lastDetectedAt: null
|
|
2163
|
+
};
|
|
2164
|
+
return {
|
|
2165
|
+
...state,
|
|
2166
|
+
autoClearAfterMs: state.detected ? require_dist.MOTION_CLOSE_AFTER_MS : null
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
1841
2169
|
});
|
|
1842
2170
|
await this.reconcile().catch((error) => {
|
|
1843
2171
|
this.ctx.logger.warn("camera grid: initial reconciliation failed — will retry", { meta: { error: require_dist.errMsg(error) } });
|
|
@@ -1907,7 +2235,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
1907
2235
|
if (!this.relay) return;
|
|
1908
2236
|
const instances = this.instances();
|
|
1909
2237
|
const declarations = buildGridCameraDeclarations(instances);
|
|
1910
|
-
await this.
|
|
2238
|
+
await this.refreshSourcePlans(instances);
|
|
1911
2239
|
await this.retireWithdrawnSessions(instances);
|
|
1912
2240
|
const result = await new require_dist.DeclaredDevices({
|
|
1913
2241
|
logger: this.ctx.logger.child("declaration"),
|
|
@@ -1974,15 +2302,73 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
1974
2302
|
}
|
|
1975
2303
|
}
|
|
1976
2304
|
/**
|
|
1977
|
-
*
|
|
2305
|
+
* Listen to the SOURCES, so the grid can say the same things about itself.
|
|
2306
|
+
*
|
|
2307
|
+
* Filtered by source id inside the handler rather than by subscribing per
|
|
2308
|
+
* camera: the bus filter is per category, the set of sources changes whenever
|
|
2309
|
+
* an operator drags a cell, and a per-camera subscription would have to be
|
|
2310
|
+
* torn down and rebuilt on every layout save.
|
|
2311
|
+
*
|
|
2312
|
+
* Every handler is TOTAL — a malformed payload is ignored, never thrown out
|
|
2313
|
+
* of. These are telemetry events on a shared bus (D8/D11), and a throw here
|
|
2314
|
+
* would take the grid's forwarding down for every grid because one camera
|
|
2315
|
+
* emitted something unexpected.
|
|
2316
|
+
*/
|
|
2317
|
+
subscribeSourceActivity() {
|
|
2318
|
+
this.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
|
|
2319
|
+
const data = event.data;
|
|
2320
|
+
if (typeof data.deviceId !== "number" || typeof data.detected !== "boolean") return;
|
|
2321
|
+
const at = typeof data.timestamp === "number" ? data.timestamp : Date.now();
|
|
2322
|
+
this.activity?.onSourceMotion(data.deviceId, data.detected, at);
|
|
2323
|
+
});
|
|
2324
|
+
this.subscribe({ category: require_dist.EventCategory.PipelineAudioInferenceResult }, (event) => {
|
|
2325
|
+
const data = event.data;
|
|
2326
|
+
const dbfs = data.frame?.level?.dbfs;
|
|
2327
|
+
if (typeof data.deviceId !== "number" || typeof dbfs !== "number") return;
|
|
2328
|
+
this.activity?.onSourceAudio(data.deviceId, dbfs, Date.now());
|
|
2329
|
+
});
|
|
2330
|
+
this.subscribe({ category: require_dist.EventCategory.DetectionResult }, (event) => {
|
|
2331
|
+
const frame = event.data.frame;
|
|
2332
|
+
if (frame === void 0 || frame.kind !== "frame") return;
|
|
2333
|
+
this.activity?.onSourceDetections(frame);
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Which grids show this camera. Empty for the overwhelming majority of
|
|
2338
|
+
* events, which is why it is the FIRST thing every handler asks.
|
|
2339
|
+
*/
|
|
2340
|
+
gridsForSource(sourceDeviceId) {
|
|
2341
|
+
const targets = [];
|
|
2342
|
+
for (const [instanceId, deviceId] of this.deviceIdByInstanceId) {
|
|
2343
|
+
const instance = this.instanceById(instanceId);
|
|
2344
|
+
if (!instance || !instance.enabled) continue;
|
|
2345
|
+
if (sourceDeviceId === deviceId) continue;
|
|
2346
|
+
if (instance.cells.filter((cell) => cell.deviceId === sourceDeviceId).length === 0) continue;
|
|
2347
|
+
targets.push({
|
|
2348
|
+
deviceId,
|
|
2349
|
+
canvas: {
|
|
2350
|
+
width: instance.width,
|
|
2351
|
+
height: instance.height
|
|
2352
|
+
},
|
|
2353
|
+
cellsForSource: (id) => instance.cells.filter((cell) => cell.deviceId === id).map((cell) => ({
|
|
2354
|
+
source: cell.source,
|
|
2355
|
+
cell: cell.cell
|
|
2356
|
+
})),
|
|
2357
|
+
sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))]
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
return targets;
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* Which stream each grid reads each of its sources at, asked of the BROKER.
|
|
1978
2364
|
*
|
|
1979
2365
|
* `listAllProfileSlots` is the one authority on "does 615 have a low
|
|
1980
2366
|
* assigned", and `sourceCamStreamId === null` is its way of saying no. A read
|
|
1981
|
-
* that FAILS leaves the previous answer in place and says so
|
|
2367
|
+
* that FAILS leaves the previous answer in place and says so — it is not an
|
|
1982
2368
|
* empty catalog, and turning it into one would retract the streams of every
|
|
1983
2369
|
* grid on the hub.
|
|
1984
2370
|
*/
|
|
1985
|
-
async
|
|
2371
|
+
async refreshSourcePlans(instances) {
|
|
1986
2372
|
let slots;
|
|
1987
2373
|
try {
|
|
1988
2374
|
slots = await this.ctx.api.streamBroker.listAllProfileSlots.query();
|
|
@@ -2001,69 +2387,70 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2001
2387
|
servedBy.set(slot.deviceId, profiles);
|
|
2002
2388
|
}
|
|
2003
2389
|
for (const instance of instances) {
|
|
2004
|
-
const
|
|
2390
|
+
const plan = gridSourceProfiles({
|
|
2005
2391
|
sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))],
|
|
2006
2392
|
profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
|
|
2007
2393
|
});
|
|
2008
|
-
const previous = this.
|
|
2009
|
-
this.
|
|
2010
|
-
this.
|
|
2394
|
+
const previous = this.planByInstanceId.get(instance.id);
|
|
2395
|
+
this.planByInstanceId.set(instance.id, plan);
|
|
2396
|
+
this.logSourcePlan(instance, plan, previous);
|
|
2011
2397
|
}
|
|
2012
2398
|
}
|
|
2013
2399
|
/**
|
|
2014
|
-
* Say
|
|
2400
|
+
* Say WHICH stream each cell is read at, and who could not be read - once per
|
|
2401
|
+
* change.
|
|
2015
2402
|
*
|
|
2016
|
-
* A
|
|
2017
|
-
* broker that swept it
|
|
2018
|
-
*
|
|
2019
|
-
*
|
|
2403
|
+
* Both halves earn their line. A grid silently missing from a catalog is
|
|
2404
|
+
* indistinguishable from a broker that swept it, so the refusal names the
|
|
2405
|
+
* sources. And the chosen profiles are the answer to "why is this tile
|
|
2406
|
+
* soft?" — a camera whose only assigned slot is `high` is read at `high`, and
|
|
2407
|
+
* nothing else in the system would ever say so.
|
|
2408
|
+
*
|
|
2409
|
+
* Logged on the EDGE: a grid that cannot be composed would otherwise write a
|
|
2410
|
+
* line a minute for ever.
|
|
2020
2411
|
*/
|
|
2021
|
-
|
|
2022
|
-
|
|
2412
|
+
logSourcePlan(instance, plan, previous) {
|
|
2413
|
+
const shape = (value) => JSON.stringify([
|
|
2414
|
+
value.offered,
|
|
2415
|
+
[...value.profileBySource],
|
|
2416
|
+
value.missingSources
|
|
2417
|
+
]);
|
|
2418
|
+
if (previous !== void 0 && shape(previous) === shape(plan)) return;
|
|
2023
2419
|
const deviceId = this.deviceIdByInstanceId.get(instance.id) ?? 0;
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
missingSources: [...offer.missingSources]
|
|
2033
|
-
}
|
|
2034
|
-
});
|
|
2035
|
-
}
|
|
2036
|
-
this.ctx.logger.info("camera grid: published profiles", {
|
|
2420
|
+
if (plan.missingSources.length > 0) this.ctx.logger.info("camera grid: a source cannot be read — the grid is NOT on offer", {
|
|
2421
|
+
tags: { deviceId },
|
|
2422
|
+
meta: {
|
|
2423
|
+
instanceId: instance.id,
|
|
2424
|
+
missingSources: [...plan.missingSources]
|
|
2425
|
+
}
|
|
2426
|
+
});
|
|
2427
|
+
this.ctx.logger.info("camera grid: composing", {
|
|
2037
2428
|
tags: { deviceId },
|
|
2038
2429
|
meta: {
|
|
2039
2430
|
instanceId: instance.id,
|
|
2040
|
-
offered:
|
|
2431
|
+
offered: plan.offered,
|
|
2432
|
+
readAt: Object.fromEntries([...plan.profileBySource].map(([id, p]) => [String(id), p]))
|
|
2041
2433
|
}
|
|
2042
2434
|
});
|
|
2043
2435
|
}
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
if (offers === void 0) return null;
|
|
2047
|
-
return offers.filter((offer) => offer.offered).map((offer) => offer.profile);
|
|
2436
|
+
planFor(instanceId) {
|
|
2437
|
+
return this.planByInstanceId.get(instanceId) ?? null;
|
|
2048
2438
|
}
|
|
2049
2439
|
ensureSessions(instanceId, deviceId) {
|
|
2050
|
-
const
|
|
2051
|
-
|
|
2052
|
-
const
|
|
2053
|
-
if (
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
this.sessions.delete(key);
|
|
2057
|
-
stale.session.dispose();
|
|
2058
|
-
}
|
|
2059
|
-
continue;
|
|
2440
|
+
const plan = this.planFor(instanceId);
|
|
2441
|
+
if (plan === null || !plan.offered) {
|
|
2442
|
+
const stale = this.sessions.get(instanceId);
|
|
2443
|
+
if (stale) {
|
|
2444
|
+
this.sessions.delete(instanceId);
|
|
2445
|
+
stale.session.dispose();
|
|
2060
2446
|
}
|
|
2061
|
-
|
|
2062
|
-
const session = this.makeSession(instanceId, deviceId, profile);
|
|
2063
|
-
this.sessions.set(key, new GridSessionEntry(instanceId, profile, session));
|
|
2447
|
+
return;
|
|
2064
2448
|
}
|
|
2449
|
+
if (this.sessions.has(instanceId)) return;
|
|
2450
|
+
const session = this.makeSession(instanceId, deviceId);
|
|
2451
|
+
this.sessions.set(instanceId, new GridSessionEntry(instanceId, session));
|
|
2065
2452
|
}
|
|
2066
|
-
makeSession(instanceId, deviceId
|
|
2453
|
+
makeSession(instanceId, deviceId) {
|
|
2067
2454
|
const childContext = {
|
|
2068
2455
|
binaryPath: this.ffmpegBinaryPath,
|
|
2069
2456
|
logger: this.ctx.logger,
|
|
@@ -2074,6 +2461,8 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2074
2461
|
deps: {
|
|
2075
2462
|
logger: this.ctx.logger,
|
|
2076
2463
|
acquireSource: async (sourceDeviceId) => {
|
|
2464
|
+
const profile = this.planFor(instanceId)?.profileBySource.get(sourceDeviceId);
|
|
2465
|
+
if (profile === void 0) throw new Error(`camera-grid: no stream is assigned for source ${String(sourceDeviceId)}`);
|
|
2077
2466
|
const dial = gridSourceDial(await acquireWithRetry(() => this.ctx.api.streamBroker.getStreamWithCodec.mutate({
|
|
2078
2467
|
deviceId: sourceDeviceId,
|
|
2079
2468
|
video: "copy",
|
|
@@ -2123,11 +2512,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2123
2512
|
if (!instance) return null;
|
|
2124
2513
|
return gridPlanFor(instance, {
|
|
2125
2514
|
encoder: "libx264",
|
|
2126
|
-
decodeHwAccel: null
|
|
2127
|
-
canvas: gridCanvasFor({
|
|
2128
|
-
width: instance.width,
|
|
2129
|
-
height: instance.height
|
|
2130
|
-
}, profile)
|
|
2515
|
+
decodeHwAccel: null
|
|
2131
2516
|
});
|
|
2132
2517
|
}
|
|
2133
2518
|
});
|
|
@@ -2135,18 +2520,14 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2135
2520
|
}
|
|
2136
2521
|
/** The session a dial is asking for, or `null` - which the relay answers 404 to. */
|
|
2137
2522
|
sessionFor(request) {
|
|
2138
|
-
return this.sessions.get(
|
|
2523
|
+
return this.sessions.get(request.instanceId)?.session ?? null;
|
|
2139
2524
|
}
|
|
2140
|
-
/** This grid's
|
|
2525
|
+
/** This grid's composition, as the list the snapshot source expects. */
|
|
2141
2526
|
sessionsForDevice(deviceId) {
|
|
2142
2527
|
const instanceId = this.instanceIdForDevice(deviceId);
|
|
2143
2528
|
if (instanceId === null) return [];
|
|
2144
|
-
const
|
|
2145
|
-
|
|
2146
|
-
const entry = this.sessions.get(sessionKey(instanceId, profile));
|
|
2147
|
-
if (entry) entries.push(entry);
|
|
2148
|
-
}
|
|
2149
|
-
return entries;
|
|
2529
|
+
const entry = this.sessions.get(instanceId);
|
|
2530
|
+
return entry ? [entry] : [];
|
|
2150
2531
|
}
|
|
2151
2532
|
/** A grid that is gone stops immediately - it must not keep N sources dialled. */
|
|
2152
2533
|
async retireWithdrawnSessions(instances) {
|
|
@@ -2155,18 +2536,16 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2155
2536
|
if (live.has(entry.instanceId)) continue;
|
|
2156
2537
|
this.ctx.logger.info("camera grid: retiring a withdrawn grid", {
|
|
2157
2538
|
tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
|
|
2158
|
-
meta: {
|
|
2159
|
-
instanceId: entry.instanceId,
|
|
2160
|
-
profile: entry.profile
|
|
2161
|
-
}
|
|
2539
|
+
meta: { instanceId: entry.instanceId }
|
|
2162
2540
|
});
|
|
2163
2541
|
this.sessions.delete(key);
|
|
2164
2542
|
await entry.session.dispose();
|
|
2165
2543
|
}
|
|
2166
2544
|
for (const instanceId of [...this.deviceIdByInstanceId.keys()]) {
|
|
2167
2545
|
if (live.has(instanceId)) continue;
|
|
2546
|
+
this.activity?.forget(this.deviceIdByInstanceId.get(instanceId) ?? 0);
|
|
2168
2547
|
this.deviceIdByInstanceId.delete(instanceId);
|
|
2169
|
-
this.
|
|
2548
|
+
this.planByInstanceId.delete(instanceId);
|
|
2170
2549
|
}
|
|
2171
2550
|
}
|
|
2172
2551
|
/**
|
|
@@ -2179,10 +2558,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2179
2558
|
if (!entry.session.isRunning()) continue;
|
|
2180
2559
|
this.ctx.logger.info("camera grid: the layout changed - restarting the composition", {
|
|
2181
2560
|
tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
|
|
2182
|
-
meta: {
|
|
2183
|
-
instanceId: entry.instanceId,
|
|
2184
|
-
profile: entry.profile
|
|
2185
|
-
}
|
|
2561
|
+
meta: { instanceId: entry.instanceId }
|
|
2186
2562
|
});
|
|
2187
2563
|
entry.session.onChildEnded("layout-changed");
|
|
2188
2564
|
}
|
|
@@ -2217,24 +2593,15 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2217
2593
|
descriptorsFor(instanceId) {
|
|
2218
2594
|
const instance = this.instanceById(instanceId);
|
|
2219
2595
|
const relay = this.relay;
|
|
2220
|
-
const
|
|
2221
|
-
if (!instance || !relay ||
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
width: canvas.width,
|
|
2230
|
-
height: canvas.height,
|
|
2231
|
-
fps: instance.fps,
|
|
2232
|
-
url: relay.streamUrl({
|
|
2233
|
-
instanceId,
|
|
2234
|
-
profile
|
|
2235
|
-
})
|
|
2236
|
-
});
|
|
2237
|
-
});
|
|
2596
|
+
const plan = this.planFor(instanceId);
|
|
2597
|
+
if (!instance || !relay || plan === null) return null;
|
|
2598
|
+
if (!plan.offered) return [];
|
|
2599
|
+
return [gridStreamDescriptor({
|
|
2600
|
+
width: instance.width,
|
|
2601
|
+
height: instance.height,
|
|
2602
|
+
fps: instance.fps,
|
|
2603
|
+
url: relay.streamUrl({ instanceId })
|
|
2604
|
+
})];
|
|
2238
2605
|
}
|
|
2239
2606
|
instanceIdForDevice(deviceId) {
|
|
2240
2607
|
for (const [instanceId, id] of this.deviceIdByInstanceId) if (id === deviceId) return instanceId;
|
|
@@ -2259,11 +2626,15 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2259
2626
|
source: cell.source,
|
|
2260
2627
|
cell: cell.cell
|
|
2261
2628
|
})),
|
|
2262
|
-
profiles: (
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2629
|
+
profiles: (() => {
|
|
2630
|
+
const plan = this.planByInstanceId.get(instance.id);
|
|
2631
|
+
if (plan === void 0) return [];
|
|
2632
|
+
return [{
|
|
2633
|
+
profile: GRID_OUTPUT_PROFILE,
|
|
2634
|
+
offered: plan.offered,
|
|
2635
|
+
missingSources: [...plan.missingSources]
|
|
2636
|
+
}];
|
|
2637
|
+
})()
|
|
2267
2638
|
};
|
|
2268
2639
|
}
|
|
2269
2640
|
async saveGridLayout(input) {
|
|
@@ -2289,7 +2660,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2289
2660
|
...row,
|
|
2290
2661
|
...updated
|
|
2291
2662
|
} : row) });
|
|
2292
|
-
await this.
|
|
2663
|
+
await this.refreshSourcePlans(this.instances());
|
|
2293
2664
|
this.ensureSessions(instance.id, input.deviceId);
|
|
2294
2665
|
const view = this.gridViewFor(input.deviceId);
|
|
2295
2666
|
if (!view) throw new Error("camera-grid: the grid vanished while it was being saved");
|
|
@@ -2357,7 +2728,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
|
|
|
2357
2728
|
//#endregion
|
|
2358
2729
|
exports.CameraGridAddon = CameraGridAddon;
|
|
2359
2730
|
exports.GRID_CAM_STREAM_ID = GRID_CAM_STREAM_ID;
|
|
2360
|
-
exports.
|
|
2731
|
+
exports.GRID_OUTPUT_PROFILE = GRID_OUTPUT_PROFILE;
|
|
2361
2732
|
exports.GRID_SILENCED_CAP_NAMES = GRID_SILENCED_CAP_NAMES;
|
|
2362
2733
|
exports.GridCameraDevice = GridCameraDevice;
|
|
2363
2734
|
exports.GridFrameSampler = GridFrameSampler;
|
|
@@ -2368,18 +2739,17 @@ exports.GridStreamSession = GridStreamSession;
|
|
|
2368
2739
|
exports.buildGridCameraDeclarations = buildGridCameraDeclarations;
|
|
2369
2740
|
exports.buildGridFilterGraph = buildGridFilterGraph;
|
|
2370
2741
|
exports.default = CameraGridAddon;
|
|
2371
|
-
exports.gridCamStreamId = gridCamStreamId;
|
|
2372
2742
|
exports.gridCameraReconciliationIndex = gridCameraReconciliationIndex;
|
|
2373
|
-
exports.gridCanvasFor = gridCanvasFor;
|
|
2374
2743
|
exports.gridPlanFor = gridPlanFor;
|
|
2375
2744
|
exports.gridSnapshotAnswerFor = gridSnapshotAnswerFor;
|
|
2376
2745
|
exports.gridSourceDial = gridSourceDial;
|
|
2746
|
+
exports.gridSourceProfiles = gridSourceProfiles;
|
|
2377
2747
|
exports.gridStreamDescriptor = gridStreamDescriptor;
|
|
2378
2748
|
exports.gridStreamInvocation = gridStreamInvocation;
|
|
2379
2749
|
exports.installGridCameraRuntime = installGridCameraRuntime;
|
|
2750
|
+
exports.lowestServedProfile = lowestServedProfile;
|
|
2380
2751
|
exports.newGridCameraStableId = newGridCameraStableId;
|
|
2381
2752
|
exports.normalizeGridRows = normalizeGridRows;
|
|
2382
|
-
exports.offeredGridProfiles = offeredGridProfiles;
|
|
2383
2753
|
exports.parseGridStreamPath = parseGridStreamPath;
|
|
2384
2754
|
exports.readGridInstances = readGridInstances;
|
|
2385
2755
|
exports.silenceAnalysisFor = silenceAnalysisFor;
|