@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.
Files changed (70) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/autotrack/index.js +1 -1
  8. package/dist/builtins/autotrack/index.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/camera-grid/addon.d.ts +38 -13
  12. package/dist/builtins/camera-grid/grid-activity-mirror.d.ts +32 -0
  13. package/dist/builtins/camera-grid/grid-activity.d.ts +50 -0
  14. package/dist/builtins/camera-grid/grid-camera-device.d.ts +11 -1
  15. package/dist/builtins/camera-grid/grid-canvas.d.ts +83 -0
  16. package/dist/builtins/camera-grid/grid-compositor.d.ts +36 -0
  17. package/dist/builtins/camera-grid/grid-detection-mapping.d.ts +31 -0
  18. package/dist/builtins/camera-grid/grid-profiles.d.ts +28 -33
  19. package/dist/builtins/camera-grid/grid-snapshot.d.ts +4 -4
  20. package/dist/builtins/camera-grid/grid-stream-descriptor.d.ts +4 -6
  21. package/dist/builtins/camera-grid/grid-stream-relay.d.ts +7 -11
  22. package/dist/builtins/camera-grid/grid-tile-feed.d.ts +38 -0
  23. package/dist/builtins/camera-grid/grid-tile-plan.d.ts +32 -0
  24. package/dist/builtins/camera-grid/index.d.ts +2 -2
  25. package/dist/builtins/camera-grid/index.js +593 -223
  26. package/dist/builtins/camera-grid/index.mjs +591 -220
  27. package/dist/builtins/camera-grid/silence-analysis.d.ts +7 -0
  28. package/dist/builtins/console-logging/index.js +1 -1
  29. package/dist/builtins/console-logging/index.mjs +1 -1
  30. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  31. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  32. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  33. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  34. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  35. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  36. package/dist/builtins/hub-forwarder/index.js +1 -1
  37. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  38. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  39. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  40. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  41. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  42. package/dist/builtins/local-network/local-network.addon.js +1 -1
  43. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  44. package/dist/builtins/loki-logging/index.js +1 -1
  45. package/dist/builtins/loki-logging/index.mjs +1 -1
  46. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  47. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  48. package/dist/builtins/platform-probe/index.js +1 -1
  49. package/dist/builtins/platform-probe/index.mjs +1 -1
  50. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  51. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  52. package/dist/builtins/snapshot/index.js +1 -1
  53. package/dist/builtins/snapshot/index.mjs +1 -1
  54. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  55. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  56. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  57. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  58. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  59. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  60. package/dist/builtins/system-config/system-config.addon.js +1 -1
  61. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  62. package/dist/builtins/winston-logging/index.js +1 -1
  63. package/dist/builtins/winston-logging/index.mjs +1 -1
  64. package/dist/{dist-6hiXbrNY.js → dist-BhYweLVr.js} +74 -15
  65. package/dist/{dist-CQ7ItBIG.mjs → dist-CO2O_ddo.mjs} +61 -8
  66. package/dist/index.js +1 -1
  67. package/dist/index.mjs +1 -1
  68. package/dist/{retired-settings-keys-CCXmaFTK.mjs → retired-settings-keys-BnnHWyfj.mjs} +1 -1
  69. package/dist/{retired-settings-keys-AmmGrCp1.js → retired-settings-keys-CKLI8VmE.js} +1 -1
  70. package/package.json +4 -1
@@ -1,4 +1,4 @@
1
- import { Cn as hydrateSchema, Qt as streamCatalogCapability, U as cameraGridLayoutCapability, Y as declarationOwnerNodeId, a as BaseDevice, an as buildFfmpegArgs, fn as DeviceType, in as errMsg, m as DeclaredDevices, n as AUDIO_ANALYSIS_CAP_NAME, on as BaseAddon, p as DETECTION_PIPELINE_CAP_NAME, qt as snapshotCapability, un as DeviceFeature } from "../../dist-CQ7ItBIG.mjs";
1
+ import { $t as streamCatalogCapability, At as motionCapability, Jt as snapshotCapability, Pn as EventCategory, U as cameraGridLayoutCapability, Y as declarationOwnerNodeId, an as errMsg, b as MOTION_CLOSE_AFTER_MS, bn as createEvent, dn as DeviceFeature, f as DERIVED_CAMERA_SILENCED_CAP_NAMES, i as BaseDevice, on as buildFfmpegArgs, p as DeclaredDevices, pn as DeviceType, sn as BaseAddon, wn as hydrateSchema } from "../../dist-CO2O_ddo.mjs";
2
2
  import { z } from "zod";
3
3
  import { FfmpegProcess } from "@camstack/types/node";
4
4
  import { join } from "node:path";
@@ -192,6 +192,10 @@ var GridCameraDevice = class extends BaseDevice {
192
192
  getSnapshot: async ({ deviceId }) => activeRuntime().getSnapshot(deviceId),
193
193
  invalidateCache: async () => {}
194
194
  });
195
+ this.ctx.registerNativeCap(motionCapability, {
196
+ getStatus: async ({ deviceId }) => activeRuntime().motionStatus(deviceId),
197
+ isDetected: async ({ deviceId }) => activeRuntime().motionStatus(deviceId).detected
198
+ });
195
199
  this.markOnline(true);
196
200
  }
197
201
  catalog() {
@@ -395,6 +399,330 @@ function normalizeGridRows(raw, mintId) {
395
399
  };
396
400
  }
397
401
  //#endregion
402
+ //#region src/builtins/camera-grid/grid-activity-mirror.ts
403
+ /**
404
+ * What a grid says about ITSELF, kept from what its sources said.
405
+ *
406
+ * ## Why the grid answers at all
407
+ *
408
+ * A grid is a camera, and the viewer already knows how to light a camera: the
409
+ * motion ring keys off `motion.on-motion-changed` filtered by `deviceId`, the
410
+ * audio one off `pipeline.audio-inference-result`. So the grid does not need a
411
+ * new channel, a new cap or a line of app code — it needs to answer those
412
+ * questions about its own device id. The operator set both rules: motion is on
413
+ * if AT LEAST ONE source has it, and the audio level is the LOUDEST of them.
414
+ *
415
+ * It is derived from the SOURCES' events and never from the composed picture.
416
+ * Analysing the composite would be a second motion authority for cameras that
417
+ * already have one, and a decode this addon does not pay today.
418
+ *
419
+ * ## The falling edge is the whole difficulty
420
+ *
421
+ * Bus events are telemetry and may be dropped (D8/D11). A lost `detected:
422
+ * false` would leave a grid lit for ever, and the grid has no phase machine to
423
+ * produce a falling edge of its own — a real camera's comes from the pipeline
424
+ * runner, which a grid deliberately does not run.
425
+ *
426
+ * So a source's motion is remembered WITH ITS TIME and expires on its own. That
427
+ * is D49's shape: in-memory state refreshed off the event path, never a gate
428
+ * that re-reads something fallible in order to decide. A dropped clear costs a
429
+ * ring that stays lit until the expiry instead of for ever, and a subject that
430
+ * is genuinely still there keeps renewing it.
431
+ *
432
+ * The expiry is `MOTION_CLOSE_AFTER_MS`, imported and not re-spelled: it is the
433
+ * system's one answer to "how long after the last push is a camera still
434
+ * moving", and a second number here would be a second authority disagreeing
435
+ * with the first on exactly the cameras that make up the grid.
436
+ */
437
+ /**
438
+ * How long an audio reading counts as current.
439
+ *
440
+ * The analyzer emits one per window per camera, so a source that has gone quiet
441
+ * simply stops arriving — there is no "silence" event to wait for. Without a
442
+ * window the loudest reading of the day would be the grid's level for ever.
443
+ * Chosen to span several analysis windows so an ordinary gap does not blink the
444
+ * level away, and to be far shorter than a person notices a stale ring.
445
+ */
446
+ var AUDIO_FRESH_MS = 5e3;
447
+ var GridActivityMirror = class {
448
+ motion = /* @__PURE__ */ new Map();
449
+ audio = /* @__PURE__ */ new Map();
450
+ lastDetectedAt = null;
451
+ /** One source's motion push. `detected: false` is as important as `true`. */
452
+ noteMotion(sourceDeviceId, detected, atMs) {
453
+ this.motion.set(sourceDeviceId, {
454
+ detected,
455
+ atMs
456
+ });
457
+ if (detected) this.lastDetectedAt = Math.max(this.lastDetectedAt ?? 0, atMs);
458
+ }
459
+ /** One source's audio window. `dbfs` is the level, on the analyzer's scale. */
460
+ noteAudio(sourceDeviceId, dbfs, atMs) {
461
+ this.audio.set(sourceDeviceId, {
462
+ dbfs,
463
+ atMs
464
+ });
465
+ }
466
+ /**
467
+ * Drop every source this grid no longer composes.
468
+ *
469
+ * A cell removed while its camera was moving would otherwise hold the grid
470
+ * lit until the expiry, for a picture the source is not even in.
471
+ */
472
+ keepOnly(sourceDeviceIds) {
473
+ const keep = new Set(sourceDeviceIds);
474
+ const motionIds = Array.from(this.motion.keys());
475
+ const audioIds = Array.from(this.audio.keys());
476
+ for (const id of motionIds) if (!keep.has(id)) this.motion.delete(id);
477
+ for (const id of audioIds) if (!keep.has(id)) this.audio.delete(id);
478
+ }
479
+ /** At least one source moving, as of `nowMs`. */
480
+ motionAt(nowMs) {
481
+ let detected = false;
482
+ for (const state of this.motion.values()) {
483
+ if (!state.detected) continue;
484
+ if (nowMs - state.atMs > 15e3) continue;
485
+ detected = true;
486
+ break;
487
+ }
488
+ return {
489
+ detected,
490
+ lastDetectedAt: this.lastDetectedAt
491
+ };
492
+ }
493
+ /**
494
+ * The loudest CURRENT reading, or `null` when nothing fresh has been heard.
495
+ *
496
+ * Null and not `-Infinity`: "nobody has reported a level" is a different
497
+ * answer from "it is silent", and a number here would have the viewer draw a
498
+ * level for a grid whose sources are not being analysed at all.
499
+ */
500
+ loudestAt(nowMs) {
501
+ let loudest = null;
502
+ for (const state of this.audio.values()) {
503
+ if (nowMs - state.atMs > AUDIO_FRESH_MS) continue;
504
+ if (loudest === null || state.dbfs > loudest) loudest = state.dbfs;
505
+ }
506
+ return loudest;
507
+ }
508
+ };
509
+ //#endregion
510
+ //#region src/builtins/camera-grid/grid-detection-mapping.ts
511
+ /**
512
+ * Where this detection lands on the canvas — once per cell that shows it.
513
+ *
514
+ * Empty is a real answer: the subject is in a part of the source this grid does
515
+ * not display, or the frame arrived with no size to measure against. Neither is
516
+ * a reason to guess.
517
+ */
518
+ function mapDetectionToCanvas(input) {
519
+ const { width: frameW, height: frameH } = input.sourceFrame;
520
+ if (frameW <= 0 || frameH <= 0) return [];
521
+ const left = input.bbox.x / frameW;
522
+ const top = input.bbox.y / frameH;
523
+ const right = (input.bbox.x + input.bbox.width) / frameW;
524
+ const bottom = (input.bbox.y + input.bbox.height) / frameH;
525
+ const out = [];
526
+ for (const { source, cell } of input.cells) {
527
+ if (source.width <= 0 || source.height <= 0) continue;
528
+ const l = Math.max(left, source.x);
529
+ const t = Math.max(top, source.y);
530
+ const r = Math.min(right, source.x + source.width);
531
+ const b = Math.min(bottom, source.y + source.height);
532
+ if (r <= l || b <= t) continue;
533
+ const cx = (l - source.x) / source.width;
534
+ const cy = (t - source.y) / source.height;
535
+ const cw = (r - l) / source.width;
536
+ const ch = (b - t) / source.height;
537
+ out.push({
538
+ x: (cell.x + cx * cell.width) * input.canvas.width,
539
+ y: (cell.y + cy * cell.height) * input.canvas.height,
540
+ width: cw * cell.width * input.canvas.width,
541
+ height: ch * cell.height * input.canvas.height
542
+ });
543
+ }
544
+ return out;
545
+ }
546
+ //#endregion
547
+ //#region src/builtins/camera-grid/grid-activity.ts
548
+ /**
549
+ * The grid's own motion / audio / detection, forwarded from its sources.
550
+ *
551
+ * ## Why the grid speaks a camera's language instead of a new one
552
+ *
553
+ * The operator asked for the grid's tile to light up in the viewer exactly as a
554
+ * camera's card does, with NO app change. The app already knows how: it filters
555
+ * `motion.on-motion-changed` by `deviceId` for the motion ring,
556
+ * `pipeline.audio-inference-result` for the audio one, and `detection.result`
557
+ * for the detection one. So the grid answers those three questions about its
558
+ * own device id, in the same shapes. Nothing new is invented, and nothing in
559
+ * the viewer is touched.
560
+ *
561
+ * The rules are the operator's: motion if AT LEAST ONE source has it, and the
562
+ * LOUDEST source's level for audio.
563
+ *
564
+ * ## What speaking a camera's language costs, and why it is safe here
565
+ *
566
+ * Those categories have other listeners, and emitting on them makes the grid a
567
+ * motion source for the whole system. Checked, one by one, before shipping:
568
+ * the recorder's event capture, the HomeKit and Alexa exports and the Home
569
+ * Assistant export are all per-device and do nothing until an operator enables
570
+ * them for this camera — and if an operator DOES export a grid to HomeKit, a
571
+ * grid reporting motion is correct, not a surprise.
572
+ *
573
+ * The one listener with no gate of its own was `pipeline-analytics`, which
574
+ * would have opened a motion EPISODE per grid, duplicating the rows its sources
575
+ * already write. Its gate turned out to be the `pipeline-analytics` WRAPPER
576
+ * BINDING, so the answer is the one the operator gave: a derived camera is
577
+ * created with the analytics wrappers OFF (`grid-analytics-defaults.ts`).
578
+ * That is a binding, not a new flag, so an operator who wants a grid analysed
579
+ * can simply turn it back on.
580
+ *
581
+ * ## Detection carries boxes whether or not anyone wanted them
582
+ *
583
+ * `detection.result` is the same event the viewer draws BOXES from, so a grid
584
+ * that reports detections reports boxes. Sending the source's own pixel box
585
+ * would draw it against the composed canvas at confidently wrong coordinates,
586
+ * so every box is carried through the cell geometry first
587
+ * (`grid-detection-mapping.ts`) and lands where the subject actually is.
588
+ */
589
+ /**
590
+ * One mirror per grid, and the emission that follows an update.
591
+ *
592
+ * Deliberately NOT one mirror per source: the question is always "is THIS grid
593
+ * lit", and a source shared by two grids answers it twice, differently, because
594
+ * the other cells differ.
595
+ */
596
+ var GridActivityForwarder = class {
597
+ deps;
598
+ mirrors = /* @__PURE__ */ new Map();
599
+ constructor(deps) {
600
+ this.deps = deps;
601
+ }
602
+ /** A source camera's motion push. */
603
+ onSourceMotion(sourceDeviceId, detected, atMs) {
604
+ for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
605
+ const mirror = this.mirrorFor(grid);
606
+ const before = mirror.motionAt(atMs).detected;
607
+ mirror.noteMotion(sourceDeviceId, detected, atMs);
608
+ const after = mirror.motionAt(atMs);
609
+ if (after.detected === before) continue;
610
+ this.emitMotion(grid.deviceId, after, atMs);
611
+ }
612
+ }
613
+ /** A source camera's audio window. */
614
+ onSourceAudio(sourceDeviceId, dbfs, atMs) {
615
+ for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
616
+ const mirror = this.mirrorFor(grid);
617
+ mirror.noteAudio(sourceDeviceId, dbfs, atMs);
618
+ const loudest = mirror.loudestAt(atMs);
619
+ if (loudest === null) continue;
620
+ this.emitAudio(grid.deviceId, loudest, atMs);
621
+ }
622
+ }
623
+ /** A source camera's detection frame, with its boxes in SOURCE pixels. */
624
+ onSourceDetections(frame) {
625
+ for (const grid of this.deps.gridsForSource(frame.deviceId)) {
626
+ const cells = grid.cellsForSource(frame.deviceId);
627
+ if (cells.length === 0) continue;
628
+ const detections = [];
629
+ for (const detection of frame.detections) for (const bbox of mapDetectionToCanvas({
630
+ bbox: detection.bbox,
631
+ sourceFrame: {
632
+ width: frame.width,
633
+ height: frame.height
634
+ },
635
+ canvas: grid.canvas,
636
+ cells
637
+ })) {
638
+ const { refinedBbox: _refined, mask: _mask, ...rest } = detection;
639
+ detections.push({
640
+ ...rest,
641
+ bbox
642
+ });
643
+ }
644
+ if (detections.length === 0) continue;
645
+ this.emitDetections(grid, frame, detections);
646
+ }
647
+ }
648
+ /** The grid's own motion answer, for `motion.getStatus`. */
649
+ motionStatusFor(deviceId, nowMs) {
650
+ return this.mirrors.get(deviceId)?.motionAt(nowMs) ?? {
651
+ detected: false,
652
+ lastDetectedAt: null
653
+ };
654
+ }
655
+ /** Drop the mirror of a grid that no longer exists. */
656
+ forget(deviceId) {
657
+ this.mirrors.delete(deviceId);
658
+ }
659
+ mirrorFor(grid) {
660
+ let mirror = this.mirrors.get(grid.deviceId);
661
+ if (mirror === void 0) {
662
+ mirror = new GridActivityMirror();
663
+ this.mirrors.set(grid.deviceId, mirror);
664
+ }
665
+ mirror.keepOnly(grid.sourceDeviceIds);
666
+ return mirror;
667
+ }
668
+ source(deviceId) {
669
+ return {
670
+ type: "device",
671
+ id: deviceId,
672
+ addonId: this.deps.addonId,
673
+ deviceId
674
+ };
675
+ }
676
+ emitMotion(deviceId, state, atMs) {
677
+ const payload = {
678
+ deviceId,
679
+ detected: state.detected,
680
+ timestamp: atMs,
681
+ source: "onboard"
682
+ };
683
+ this.deps.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, this.source(deviceId), payload));
684
+ this.deps.logger.debug("camera grid: motion forwarded", {
685
+ tags: { deviceId },
686
+ meta: { detected: state.detected }
687
+ });
688
+ }
689
+ emitAudio(deviceId, dbfs, atMs) {
690
+ const payload = {
691
+ deviceId,
692
+ frame: {
693
+ kind: "audio-window",
694
+ windowId: `grid-${String(deviceId)}-${String(atMs)}`,
695
+ deviceId,
696
+ timestamp: atMs,
697
+ startMs: atMs,
698
+ endMs: atMs,
699
+ level: {
700
+ rms: 0,
701
+ dbfs
702
+ },
703
+ detections: []
704
+ },
705
+ nodeId: "hub"
706
+ };
707
+ this.deps.eventBus.emit(createEvent(EventCategory.PipelineAudioInferenceResult, this.source(deviceId), payload));
708
+ }
709
+ emitDetections(grid, sourceFrame, detections) {
710
+ const payload = {
711
+ frame: {
712
+ kind: "frame",
713
+ frameId: `grid-${String(grid.deviceId)}-${sourceFrame.frameId}`,
714
+ deviceId: grid.deviceId,
715
+ timestamp: sourceFrame.timestamp,
716
+ width: grid.canvas.width,
717
+ height: grid.canvas.height,
718
+ detections
719
+ },
720
+ analysisResults: []
721
+ };
722
+ this.deps.eventBus.emit(createEvent(EventCategory.DetectionResult, this.source(grid.deviceId), payload));
723
+ }
724
+ };
725
+ //#endregion
398
726
  //#region src/builtins/camera-grid/grid-wire-format.ts
399
727
  /** What the relay answers with, and what the sampler must demux. */
400
728
  var GRID_WIRE_CONTENT_TYPE = "video/x-flv";
@@ -595,82 +923,50 @@ var GridLastFrameFileStore = class {
595
923
  //#endregion
596
924
  //#region src/builtins/camera-grid/grid-profiles.ts
597
925
  /**
598
- * One id, for every grid, forever — for the HIGH composition.
926
+ * One id, for every grid, forever.
599
927
  *
600
- * D519's reason still holds and is what keeps this id bare: a per-instance or
601
- * newly-spelled id mints a second cam-stream and orphans whatever profile
602
- * assignment the operator had made. `grid` was `high` on the live hub before
603
- * this change, so `high` keeps it and the other two are new.
928
+ * D519's reason is what keeps this id bare: a per-instance or newly-spelled id
929
+ * mints a second cam-stream and orphans whatever profile assignment the
930
+ * operator had made. It was `high` on the live hub before the per-profile
931
+ * detour and it is `high` again, so nothing an operator assigned is disturbed.
604
932
  */
605
933
  var GRID_CAM_STREAM_ID = "grid";
606
- /** Every profile a grid could conceivably compose, in descending quality. */
607
- var GRID_PROFILES = [
608
- "high",
609
- "mid",
610
- "low"
611
- ];
612
934
  /**
613
- * Canvas scale per profile. Named constants and not a computation: the numbers
614
- * are a policy (what a `mid` grid is FOR), and a policy that lives in an
615
- * expression is a policy nobody can find.
616
- */
617
- var CANVAS_SCALE = {
618
- high: 1,
619
- mid: .5,
620
- low: .25
621
- };
622
- /**
623
- * The smallest canvas a composition is allowed to shrink to. A grid of four
624
- * cells at 40x30 is not a cheaper picture, it is no picture.
935
+ * The slot the single composition claims.
936
+ *
937
+ * NOT decoration. A device publishing one stream with no hint is ranked by
938
+ * pixel count and lands in `mid` — the slot `recordings` does not select on its
939
+ * own, that `recordingsLow` ignores, and that scrub never reads. The Dreame
940
+ * robot's single 720p relay landed exactly there and its footage was written
941
+ * where nothing looked.
625
942
  */
626
- var MIN_CANVAS_WIDTH = 160;
627
- var MIN_CANVAS_HEIGHT = 90;
628
- /** The cam-stream id a grid publishes for one profile. */
629
- function gridCamStreamId(profile) {
630
- return profile === "high" ? GRID_CAM_STREAM_ID : `${GRID_CAM_STREAM_ID}-${profile}`;
943
+ var GRID_OUTPUT_PROFILE = "high";
944
+ /** Cheapest first — the order a source is read in. */
945
+ var CHEAPEST_FIRST = [
946
+ "low",
947
+ "mid",
948
+ "high"
949
+ ];
950
+ /** The cheapest stream this source serves, or `null` when it serves none. */
951
+ function lowestServedProfile(served) {
952
+ return CHEAPEST_FIRST.find((profile) => served.includes(profile)) ?? null;
631
953
  }
632
- /** Even, floored, scaled by the profile. libx264 refuses an odd dimension. */
633
- function gridCanvasFor(canvas, profile) {
634
- const scale = CANVAS_SCALE[profile];
954
+ /** Which stream to read each source at, and who cannot be read at all. */
955
+ function gridSourceProfiles(input) {
956
+ const profileBySource = /* @__PURE__ */ new Map();
957
+ const missingSources = [];
958
+ for (const deviceId of input.sourceDeviceIds) {
959
+ const served = input.profilesServedBy(deviceId);
960
+ const chosen = served === null ? null : lowestServedProfile(served);
961
+ if (chosen === null) missingSources.push(deviceId);
962
+ else profileBySource.set(deviceId, chosen);
963
+ }
635
964
  return {
636
- width: evenAtLeast(canvas.width * scale, MIN_CANVAS_WIDTH),
637
- height: evenAtLeast(canvas.height * scale, MIN_CANVAS_HEIGHT)
965
+ offered: input.sourceDeviceIds.length > 0 && missingSources.length === 0,
966
+ profileBySource,
967
+ missingSources
638
968
  };
639
969
  }
640
- function evenAtLeast(value, floor) {
641
- const rounded = Math.max(floor, Math.round(value));
642
- return rounded % 2 === 0 ? rounded : rounded + 1;
643
- }
644
- /**
645
- * What this grid can honestly publish, per profile, with the refusal named.
646
- *
647
- * Every profile gets a row whether it is offered or not: an absent row says
648
- * nothing, and the caller logging a refusal needs the reason more than the
649
- * caller publishing an offer needs the offer.
650
- */
651
- function offeredGridProfiles(input) {
652
- const served = /* @__PURE__ */ new Map();
653
- for (const deviceId of input.sourceDeviceIds) served.set(deviceId, input.profilesServedBy(deviceId));
654
- return GRID_PROFILES.map((profile) => {
655
- if (input.sourceDeviceIds.length === 0) return {
656
- profile,
657
- offered: false,
658
- missingSources: []
659
- };
660
- const missingSources = input.sourceDeviceIds.filter((deviceId) => {
661
- const profiles = served.get(deviceId);
662
- return profiles === null || profiles === void 0 || !profiles.includes(profile);
663
- });
664
- return missingSources.length === 0 ? {
665
- profile,
666
- offered: true
667
- } : {
668
- profile,
669
- offered: false,
670
- missingSources
671
- };
672
- });
673
- }
674
970
  //#endregion
675
971
  //#region src/builtins/camera-grid/grid-sentry-sources.ts
676
972
  function gridSourceDial(acquired) {
@@ -689,6 +985,43 @@ function gridSourceDial(acquired) {
689
985
  //#endregion
690
986
  //#region src/builtins/camera-grid/grid-snapshot.ts
691
987
  /**
988
+ * A grid's snapshot — a frame of the COMPOSITION, never a dial.
989
+ *
990
+ * ## The decision, and the two things it rejects
991
+ *
992
+ * The operator: *"Lo snapshot possiamo sempre farlo dallo stream sempre, non
993
+ * c'e' bisogno di comporre gli snapshots"*. So:
994
+ *
995
+ * - **Not composed from source snapshots.** That would be a SECOND compositor,
996
+ * with its own geometry, its own aspect handling and its own bugs, producing
997
+ * a picture that differs from the real one in ways nobody would ever notice
998
+ * until they mattered.
999
+ * - **Not produced by starting the composition.** The whole of D519 is "a grid
1000
+ * costs nothing when nobody watches". A thumbnail that dials N cameras — and
1001
+ * on a battery camera, wakes one — spends the entire budget of the feature on
1002
+ * a 160-pixel-wide picture in a list.
1003
+ *
1004
+ * ## The three states, all answered
1005
+ *
1006
+ * | state | answer |
1007
+ * | --- | --- |
1008
+ * | a composition is running | one frame sampled out of the bytes it is already emitting |
1009
+ * | nothing running, a frame was kept | that frame — stale, and honest about it |
1010
+ * | nothing running, nothing ever kept | `null`, logged with the reason |
1011
+ *
1012
+ * `null` is the courtesy card, which is the correct picture of "this grid has
1013
+ * never been watched". What it must never be is a reason to dial.
1014
+ *
1015
+ * ## Why this is a NATIVE snapshot provider
1016
+ *
1017
+ * The wrapper's ladder is native-first: a native that produces a frame
1018
+ * short-circuits before the broker frame-grab, and the broker frame-grab is the
1019
+ * thing that would wake the sources. A grid with a stored frame or a live
1020
+ * composition therefore never reaches it. The residual — a grid that has never
1021
+ * once been composed — is documented in D519; it cannot wake a battery camera,
1022
+ * because a grid never dials one live at all (`grid-sentry-sources.ts`).
1023
+ */
1024
+ /**
692
1025
  * Which of the three answers applies. Pure, so the rule can be read — and
693
1026
  * sabotaged — without a socket, a file or an ffmpeg.
694
1027
  */
@@ -721,10 +1054,7 @@ var GridSnapshotSource = class {
721
1054
  return toImage(sampled);
722
1055
  }
723
1056
  if (stored !== null) return toImage(stored);
724
- this.deps.logger.warn("camera grid: the composition is running but produced no frame, and none was kept", {
725
- tags: { deviceId },
726
- meta: { profile: live.profile }
727
- });
1057
+ this.deps.logger.warn("camera grid: the composition is running but produced no frame, and none was kept", { tags: { deviceId } });
728
1058
  return null;
729
1059
  }
730
1060
  if (answer.kind === "stored" && stored !== null) return toImage(stored);
@@ -732,7 +1062,7 @@ var GridSnapshotSource = class {
732
1062
  tags: { deviceId },
733
1063
  meta: {
734
1064
  reason: "never-composed",
735
- profiles: sessions.length
1065
+ compositions: sessions.length
736
1066
  }
737
1067
  });
738
1068
  return null;
@@ -741,18 +1071,12 @@ var GridSnapshotSource = class {
741
1071
  try {
742
1072
  const jpeg = await this.deps.sampleFrame(session);
743
1073
  if (jpeg !== null && jpeg.length > 0) return jpeg;
744
- this.deps.logger.warn("camera grid: could not sample a frame from the live composition", {
745
- tags: { deviceId },
746
- meta: { profile: session.profile }
747
- });
1074
+ this.deps.logger.warn("camera grid: could not sample a frame from the live composition", { tags: { deviceId } });
748
1075
  return null;
749
1076
  } catch (error) {
750
1077
  this.deps.logger.warn("camera grid: could not sample a frame from the live composition", {
751
1078
  tags: { deviceId },
752
- meta: {
753
- profile: session.profile,
754
- error: errMsg(error)
755
- }
1079
+ meta: { error: errMsg(error) }
756
1080
  });
757
1081
  return null;
758
1082
  }
@@ -806,24 +1130,15 @@ var PATH_PREFIX = "/grid/";
806
1130
  var PATH_SUFFIX = GRID_WIRE_PATH_SUFFIX;
807
1131
  /** How long a reader may refuse to drain before it is cut. */
808
1132
  var SLOW_READER_MS = 5e3;
809
- function asProfile(raw) {
810
- return GRID_PROFILES.find((profile) => profile === raw) ?? null;
811
- }
812
- /** `/grid/<instanceId>/<profile>.flv`, or `null` when it is anything else. */
1133
+ /** `/grid/<instanceId>.flv`, or `null` when it is anything else. */
813
1134
  function parseGridStreamPath(url) {
814
1135
  const path = (url ?? "").split("?")[0] ?? "";
815
1136
  if (!path.startsWith(PATH_PREFIX) || !path.endsWith(PATH_SUFFIX)) return null;
816
1137
  const raw = path.slice(6, path.length - PATH_SUFFIX.length);
817
- const at = raw.lastIndexOf("/");
818
- if (at <= 0) return null;
819
- const profile = asProfile(raw.slice(at + 1));
820
- if (profile === null) return null;
1138
+ if (raw.length === 0 || raw.includes("/")) return null;
821
1139
  try {
822
- const instanceId = decodeURIComponent(raw.slice(0, at));
823
- return instanceId.length > 0 ? {
824
- instanceId,
825
- profile
826
- } : null;
1140
+ const instanceId = decodeURIComponent(raw);
1141
+ return instanceId.length > 0 ? { instanceId } : null;
827
1142
  } catch {
828
1143
  return null;
829
1144
  }
@@ -862,16 +1177,13 @@ var GridStreamRelay = class {
862
1177
  streamUrl(request) {
863
1178
  if (!this.baseUrl) throw new Error("camera-grid: the stream relay is not started");
864
1179
  const instance = encodeURIComponent(request.instanceId);
865
- return `${this.baseUrl}${PATH_PREFIX}${instance}/${request.profile}${PATH_SUFFIX}`;
1180
+ return `${this.baseUrl}${PATH_PREFIX}${instance}${PATH_SUFFIX}`;
866
1181
  }
867
1182
  async serve(request, res) {
868
- const { instanceId, profile } = request;
1183
+ const { instanceId } = request;
869
1184
  const session = this.deps.sessionFor(request);
870
1185
  if (session === null) {
871
- this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
872
- instanceId,
873
- profile
874
- } });
1186
+ this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: { instanceId } });
875
1187
  res.writeHead(404).end();
876
1188
  return;
877
1189
  }
@@ -905,7 +1217,6 @@ var GridStreamRelay = class {
905
1217
  tags: { deviceId: session.deviceId },
906
1218
  meta: {
907
1219
  instanceId,
908
- profile,
909
1220
  stalledForMs: SLOW_READER_MS
910
1221
  }
911
1222
  });
@@ -918,7 +1229,6 @@ var GridStreamRelay = class {
918
1229
  tags: { deviceId: session.deviceId },
919
1230
  meta: {
920
1231
  instanceId,
921
- profile,
922
1232
  reason
923
1233
  }
924
1234
  });
@@ -931,7 +1241,6 @@ var GridStreamRelay = class {
931
1241
  tags: { deviceId: session.deviceId },
932
1242
  meta: {
933
1243
  instanceId,
934
- profile,
935
1244
  error: errMsg(error)
936
1245
  }
937
1246
  });
@@ -967,13 +1276,13 @@ var GridStreamRelay = class {
967
1276
  * scrub never reads. The Dreame robot's one 720p relay landed exactly there and
968
1277
  * its footage was written nowhere anything looked.
969
1278
  *
970
- * With several compositions published the hint does a second job: it is the
971
- * only thing that says which slot each one is FOR. Ranked by pixels a grid's
972
- * `mid` would take the `high` slot on any grid whose `high` was not offered.
1279
+ * So the single composition claims `high` EXPLICITLY (`GRID_OUTPUT_PROFILE`).
1280
+ * It is also the slot this stream held before the per-profile detour, which is
1281
+ * what keeps an operator's existing assignment intact.
973
1282
  */
974
1283
  function gridStreamDescriptor(input) {
975
1284
  return {
976
- camStreamId: gridCamStreamId(input.profile),
1285
+ camStreamId: GRID_CAM_STREAM_ID,
977
1286
  kind: GRID_WIRE_STREAM_KIND,
978
1287
  url: input.url,
979
1288
  codec: "h264",
@@ -982,8 +1291,8 @@ function gridStreamDescriptor(input) {
982
1291
  height: input.height
983
1292
  },
984
1293
  fps: input.fps,
985
- label: `Grid composition (${input.profile})`,
986
- profileHint: input.profile,
1294
+ label: "Grid composition",
1295
+ profileHint: GRID_OUTPUT_PROFILE,
987
1296
  autoEligible: true
988
1297
  };
989
1298
  }
@@ -1416,11 +1725,14 @@ var GridStreamSession = class {
1416
1725
  * for a grid must win, and a reconcile that re-asserted every pass would silently
1417
1726
  * overrule them once a minute.
1418
1727
  */
1419
- var GRID_SILENCED_CAP_NAMES = [
1420
- "motion-detection",
1421
- DETECTION_PIPELINE_CAP_NAME,
1422
- AUDIO_ANALYSIS_CAP_NAME
1423
- ];
1728
+ /**
1729
+ * The list lives in `@camstack/types` now, and this is an alias rather than a
1730
+ * second copy. There WERE two — this one and the Terminal addon's — and they
1731
+ * had drifted: neither named `pipeline-analytics`, the one cap with no gate of
1732
+ * its own, which is what a grid forwarding its sources' motion would have used
1733
+ * to write a duplicate motion episode per event.
1734
+ */
1735
+ var GRID_SILENCED_CAP_NAMES = DERIVED_CAMERA_SILENCED_CAP_NAMES;
1424
1736
  /**
1425
1737
  * Resolve the addon currently providing `capName` for this device, from the
1426
1738
  * device's OWN bindings. `listBindableCapsForDeviceType` answers for the device
@@ -1761,21 +2073,18 @@ var DEFAULTS = {
1761
2073
  cameraGrids: [],
1762
2074
  cameraGridTombstones: []
1763
2075
  };
1764
- /** One session per (grid, profile). A grid composes each profile separately. */
1765
- function sessionKey(instanceId, profile) {
1766
- return `${instanceId}::${profile}`;
1767
- }
1768
2076
  /**
1769
- * One grid's composition of one profile, and enough about it for the snapshot
1770
- * to choose between them without reaching into the session.
2077
+ * One grid's composition — there is exactly one, keyed by the grid itself.
2078
+ *
2079
+ * It used to be one per profile, and the key carried the profile. That shape
2080
+ * is what made a viewer's adaptive step tear a composition down and build
2081
+ * another; see `grid-profiles.ts` for the log that showed it.
1771
2082
  */
1772
2083
  var GridSessionEntry = class {
1773
2084
  instanceId;
1774
- profile;
1775
2085
  session;
1776
- constructor(instanceId, profile, session) {
2086
+ constructor(instanceId, session) {
1777
2087
  this.instanceId = instanceId;
1778
- this.profile = profile;
1779
2088
  this.session = session;
1780
2089
  }
1781
2090
  isRunning() {
@@ -1785,7 +2094,7 @@ var GridSessionEntry = class {
1785
2094
  var CameraGridAddon = class extends BaseAddon {
1786
2095
  relay = null;
1787
2096
  reconcileTimer = null;
1788
- /** Keyed by {@link sessionKey} — one composition per grid per profile. */
2097
+ /** Keyed by instance id — ONE composition per grid. */
1789
2098
  sessions = /* @__PURE__ */ new Map();
1790
2099
  deviceIdByInstanceId = /* @__PURE__ */ new Map();
1791
2100
  tombstones = /* @__PURE__ */ new Set();
@@ -1799,7 +2108,8 @@ var CameraGridAddon = class extends BaseAddon {
1799
2108
  * (D224), and 60 s is the window for a fact — "does 615 have a low assigned"
1800
2109
  * — that only changes when an operator changes it.
1801
2110
  */
1802
- offersByInstanceId = /* @__PURE__ */ new Map();
2111
+ planByInstanceId = /* @__PURE__ */ new Map();
2112
+ activity = null;
1803
2113
  snapshots = null;
1804
2114
  frameSampler = null;
1805
2115
  ffmpegBinaryPath = "ffmpeg";
@@ -1828,11 +2138,29 @@ var CameraGridAddon = class extends BaseAddon {
1828
2138
  sampleFrame: async (session) => this.sampleFrame(session),
1829
2139
  store: new GridLastFrameFileStore({ dataDir: this.ctx.dataDir })
1830
2140
  });
2141
+ this.activity = new GridActivityForwarder({
2142
+ eventBus: this.ctx.eventBus,
2143
+ logger: this.ctx.logger.child("activity"),
2144
+ addonId: this.ctx.id,
2145
+ gridsForSource: (sourceDeviceId) => this.gridsForSource(sourceDeviceId),
2146
+ now: () => Date.now()
2147
+ });
2148
+ this.subscribeSourceActivity();
1831
2149
  installGridCameraRuntime({
1832
2150
  descriptorsFor: (instanceId) => this.descriptorsFor(instanceId),
1833
2151
  getLayout: (deviceId) => this.gridViewFor(deviceId),
1834
2152
  saveLayout: async (patch) => this.saveGridLayout(patch),
1835
- getSnapshot: async (deviceId) => this.getSnapshot(deviceId)
2153
+ getSnapshot: async (deviceId) => this.getSnapshot(deviceId),
2154
+ motionStatus: (deviceId) => {
2155
+ const state = this.activity?.motionStatusFor(deviceId, Date.now()) ?? {
2156
+ detected: false,
2157
+ lastDetectedAt: null
2158
+ };
2159
+ return {
2160
+ ...state,
2161
+ autoClearAfterMs: state.detected ? MOTION_CLOSE_AFTER_MS : null
2162
+ };
2163
+ }
1836
2164
  });
1837
2165
  await this.reconcile().catch((error) => {
1838
2166
  this.ctx.logger.warn("camera grid: initial reconciliation failed — will retry", { meta: { error: errMsg(error) } });
@@ -1902,7 +2230,7 @@ var CameraGridAddon = class extends BaseAddon {
1902
2230
  if (!this.relay) return;
1903
2231
  const instances = this.instances();
1904
2232
  const declarations = buildGridCameraDeclarations(instances);
1905
- await this.refreshProfileOffers(instances);
2233
+ await this.refreshSourcePlans(instances);
1906
2234
  await this.retireWithdrawnSessions(instances);
1907
2235
  const result = await new DeclaredDevices({
1908
2236
  logger: this.ctx.logger.child("declaration"),
@@ -1969,15 +2297,73 @@ var CameraGridAddon = class extends BaseAddon {
1969
2297
  }
1970
2298
  }
1971
2299
  /**
1972
- * Which profiles each grid can honestly publish, asked of the BROKER.
2300
+ * Listen to the SOURCES, so the grid can say the same things about itself.
2301
+ *
2302
+ * Filtered by source id inside the handler rather than by subscribing per
2303
+ * camera: the bus filter is per category, the set of sources changes whenever
2304
+ * an operator drags a cell, and a per-camera subscription would have to be
2305
+ * torn down and rebuilt on every layout save.
2306
+ *
2307
+ * Every handler is TOTAL — a malformed payload is ignored, never thrown out
2308
+ * of. These are telemetry events on a shared bus (D8/D11), and a throw here
2309
+ * would take the grid's forwarding down for every grid because one camera
2310
+ * emitted something unexpected.
2311
+ */
2312
+ subscribeSourceActivity() {
2313
+ this.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
2314
+ const data = event.data;
2315
+ if (typeof data.deviceId !== "number" || typeof data.detected !== "boolean") return;
2316
+ const at = typeof data.timestamp === "number" ? data.timestamp : Date.now();
2317
+ this.activity?.onSourceMotion(data.deviceId, data.detected, at);
2318
+ });
2319
+ this.subscribe({ category: EventCategory.PipelineAudioInferenceResult }, (event) => {
2320
+ const data = event.data;
2321
+ const dbfs = data.frame?.level?.dbfs;
2322
+ if (typeof data.deviceId !== "number" || typeof dbfs !== "number") return;
2323
+ this.activity?.onSourceAudio(data.deviceId, dbfs, Date.now());
2324
+ });
2325
+ this.subscribe({ category: EventCategory.DetectionResult }, (event) => {
2326
+ const frame = event.data.frame;
2327
+ if (frame === void 0 || frame.kind !== "frame") return;
2328
+ this.activity?.onSourceDetections(frame);
2329
+ });
2330
+ }
2331
+ /**
2332
+ * Which grids show this camera. Empty for the overwhelming majority of
2333
+ * events, which is why it is the FIRST thing every handler asks.
2334
+ */
2335
+ gridsForSource(sourceDeviceId) {
2336
+ const targets = [];
2337
+ for (const [instanceId, deviceId] of this.deviceIdByInstanceId) {
2338
+ const instance = this.instanceById(instanceId);
2339
+ if (!instance || !instance.enabled) continue;
2340
+ if (sourceDeviceId === deviceId) continue;
2341
+ if (instance.cells.filter((cell) => cell.deviceId === sourceDeviceId).length === 0) continue;
2342
+ targets.push({
2343
+ deviceId,
2344
+ canvas: {
2345
+ width: instance.width,
2346
+ height: instance.height
2347
+ },
2348
+ cellsForSource: (id) => instance.cells.filter((cell) => cell.deviceId === id).map((cell) => ({
2349
+ source: cell.source,
2350
+ cell: cell.cell
2351
+ })),
2352
+ sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))]
2353
+ });
2354
+ }
2355
+ return targets;
2356
+ }
2357
+ /**
2358
+ * Which stream each grid reads each of its sources at, asked of the BROKER.
1973
2359
  *
1974
2360
  * `listAllProfileSlots` is the one authority on "does 615 have a low
1975
2361
  * assigned", and `sourceCamStreamId === null` is its way of saying no. A read
1976
- * that FAILS leaves the previous answer in place and says so - it is not an
2362
+ * that FAILS leaves the previous answer in place and says so — it is not an
1977
2363
  * empty catalog, and turning it into one would retract the streams of every
1978
2364
  * grid on the hub.
1979
2365
  */
1980
- async refreshProfileOffers(instances) {
2366
+ async refreshSourcePlans(instances) {
1981
2367
  let slots;
1982
2368
  try {
1983
2369
  slots = await this.ctx.api.streamBroker.listAllProfileSlots.query();
@@ -1996,69 +2382,70 @@ var CameraGridAddon = class extends BaseAddon {
1996
2382
  servedBy.set(slot.deviceId, profiles);
1997
2383
  }
1998
2384
  for (const instance of instances) {
1999
- const offers = offeredGridProfiles({
2385
+ const plan = gridSourceProfiles({
2000
2386
  sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))],
2001
2387
  profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
2002
2388
  });
2003
- const previous = this.offersByInstanceId.get(instance.id);
2004
- this.offersByInstanceId.set(instance.id, offers);
2005
- this.logRefusals(instance, offers, previous);
2389
+ const previous = this.planByInstanceId.get(instance.id);
2390
+ this.planByInstanceId.set(instance.id, plan);
2391
+ this.logSourcePlan(instance, plan, previous);
2006
2392
  }
2007
2393
  }
2008
2394
  /**
2009
- * Say which profiles this grid will NOT publish, and why - once per change.
2395
+ * Say WHICH stream each cell is read at, and who could not be read - once per
2396
+ * change.
2010
2397
  *
2011
- * A profile silently missing from a catalog is indistinguishable from a
2012
- * broker that swept it. Logged on the EDGE rather than every minute: a grid
2013
- * that can only serve `high` would otherwise write two lines a minute for
2014
- * ever.
2398
+ * Both halves earn their line. A grid silently missing from a catalog is
2399
+ * indistinguishable from a broker that swept it, so the refusal names the
2400
+ * sources. And the chosen profiles are the answer to "why is this tile
2401
+ * soft?" — a camera whose only assigned slot is `high` is read at `high`, and
2402
+ * nothing else in the system would ever say so.
2403
+ *
2404
+ * Logged on the EDGE: a grid that cannot be composed would otherwise write a
2405
+ * line a minute for ever.
2015
2406
  */
2016
- logRefusals(instance, offers, previous) {
2017
- if (previous !== void 0 && JSON.stringify(previous) === JSON.stringify(offers)) return;
2407
+ logSourcePlan(instance, plan, previous) {
2408
+ const shape = (value) => JSON.stringify([
2409
+ value.offered,
2410
+ [...value.profileBySource],
2411
+ value.missingSources
2412
+ ]);
2413
+ if (previous !== void 0 && shape(previous) === shape(plan)) return;
2018
2414
  const deviceId = this.deviceIdByInstanceId.get(instance.id) ?? 0;
2019
- for (const offer of offers) {
2020
- if (offer.offered) continue;
2021
- if (offer.missingSources.length === 0) continue;
2022
- this.ctx.logger.info("camera grid: a profile is NOT on offer", {
2023
- tags: { deviceId },
2024
- meta: {
2025
- instanceId: instance.id,
2026
- profile: offer.profile,
2027
- missingSources: [...offer.missingSources]
2028
- }
2029
- });
2030
- }
2031
- this.ctx.logger.info("camera grid: published profiles", {
2415
+ if (plan.missingSources.length > 0) this.ctx.logger.info("camera grid: a source cannot be read — the grid is NOT on offer", {
2416
+ tags: { deviceId },
2417
+ meta: {
2418
+ instanceId: instance.id,
2419
+ missingSources: [...plan.missingSources]
2420
+ }
2421
+ });
2422
+ this.ctx.logger.info("camera grid: composing", {
2032
2423
  tags: { deviceId },
2033
2424
  meta: {
2034
2425
  instanceId: instance.id,
2035
- offered: offers.filter((offer) => offer.offered).map((offer) => offer.profile)
2426
+ offered: plan.offered,
2427
+ readAt: Object.fromEntries([...plan.profileBySource].map(([id, p]) => [String(id), p]))
2036
2428
  }
2037
2429
  });
2038
2430
  }
2039
- offeredProfiles(instanceId) {
2040
- const offers = this.offersByInstanceId.get(instanceId);
2041
- if (offers === void 0) return null;
2042
- return offers.filter((offer) => offer.offered).map((offer) => offer.profile);
2431
+ planFor(instanceId) {
2432
+ return this.planByInstanceId.get(instanceId) ?? null;
2043
2433
  }
2044
2434
  ensureSessions(instanceId, deviceId) {
2045
- const offered = this.offeredProfiles(instanceId) ?? [];
2046
- for (const profile of GRID_PROFILES) {
2047
- const key = sessionKey(instanceId, profile);
2048
- if (!offered.includes(profile)) {
2049
- const stale = this.sessions.get(key);
2050
- if (stale) {
2051
- this.sessions.delete(key);
2052
- stale.session.dispose();
2053
- }
2054
- continue;
2435
+ const plan = this.planFor(instanceId);
2436
+ if (plan === null || !plan.offered) {
2437
+ const stale = this.sessions.get(instanceId);
2438
+ if (stale) {
2439
+ this.sessions.delete(instanceId);
2440
+ stale.session.dispose();
2055
2441
  }
2056
- if (this.sessions.has(key)) continue;
2057
- const session = this.makeSession(instanceId, deviceId, profile);
2058
- this.sessions.set(key, new GridSessionEntry(instanceId, profile, session));
2442
+ return;
2059
2443
  }
2444
+ if (this.sessions.has(instanceId)) return;
2445
+ const session = this.makeSession(instanceId, deviceId);
2446
+ this.sessions.set(instanceId, new GridSessionEntry(instanceId, session));
2060
2447
  }
2061
- makeSession(instanceId, deviceId, profile) {
2448
+ makeSession(instanceId, deviceId) {
2062
2449
  const childContext = {
2063
2450
  binaryPath: this.ffmpegBinaryPath,
2064
2451
  logger: this.ctx.logger,
@@ -2069,6 +2456,8 @@ var CameraGridAddon = class extends BaseAddon {
2069
2456
  deps: {
2070
2457
  logger: this.ctx.logger,
2071
2458
  acquireSource: async (sourceDeviceId) => {
2459
+ const profile = this.planFor(instanceId)?.profileBySource.get(sourceDeviceId);
2460
+ if (profile === void 0) throw new Error(`camera-grid: no stream is assigned for source ${String(sourceDeviceId)}`);
2072
2461
  const dial = gridSourceDial(await acquireWithRetry(() => this.ctx.api.streamBroker.getStreamWithCodec.mutate({
2073
2462
  deviceId: sourceDeviceId,
2074
2463
  video: "copy",
@@ -2118,11 +2507,7 @@ var CameraGridAddon = class extends BaseAddon {
2118
2507
  if (!instance) return null;
2119
2508
  return gridPlanFor(instance, {
2120
2509
  encoder: "libx264",
2121
- decodeHwAccel: null,
2122
- canvas: gridCanvasFor({
2123
- width: instance.width,
2124
- height: instance.height
2125
- }, profile)
2510
+ decodeHwAccel: null
2126
2511
  });
2127
2512
  }
2128
2513
  });
@@ -2130,18 +2515,14 @@ var CameraGridAddon = class extends BaseAddon {
2130
2515
  }
2131
2516
  /** The session a dial is asking for, or `null` - which the relay answers 404 to. */
2132
2517
  sessionFor(request) {
2133
- return this.sessions.get(sessionKey(request.instanceId, request.profile))?.session ?? null;
2518
+ return this.sessions.get(request.instanceId)?.session ?? null;
2134
2519
  }
2135
- /** This grid's sessions, in DESCENDING quality - the snapshot takes the first running one. */
2520
+ /** This grid's composition, as the list the snapshot source expects. */
2136
2521
  sessionsForDevice(deviceId) {
2137
2522
  const instanceId = this.instanceIdForDevice(deviceId);
2138
2523
  if (instanceId === null) return [];
2139
- const entries = [];
2140
- for (const profile of GRID_PROFILES) {
2141
- const entry = this.sessions.get(sessionKey(instanceId, profile));
2142
- if (entry) entries.push(entry);
2143
- }
2144
- return entries;
2524
+ const entry = this.sessions.get(instanceId);
2525
+ return entry ? [entry] : [];
2145
2526
  }
2146
2527
  /** A grid that is gone stops immediately - it must not keep N sources dialled. */
2147
2528
  async retireWithdrawnSessions(instances) {
@@ -2150,18 +2531,16 @@ var CameraGridAddon = class extends BaseAddon {
2150
2531
  if (live.has(entry.instanceId)) continue;
2151
2532
  this.ctx.logger.info("camera grid: retiring a withdrawn grid", {
2152
2533
  tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
2153
- meta: {
2154
- instanceId: entry.instanceId,
2155
- profile: entry.profile
2156
- }
2534
+ meta: { instanceId: entry.instanceId }
2157
2535
  });
2158
2536
  this.sessions.delete(key);
2159
2537
  await entry.session.dispose();
2160
2538
  }
2161
2539
  for (const instanceId of [...this.deviceIdByInstanceId.keys()]) {
2162
2540
  if (live.has(instanceId)) continue;
2541
+ this.activity?.forget(this.deviceIdByInstanceId.get(instanceId) ?? 0);
2163
2542
  this.deviceIdByInstanceId.delete(instanceId);
2164
- this.offersByInstanceId.delete(instanceId);
2543
+ this.planByInstanceId.delete(instanceId);
2165
2544
  }
2166
2545
  }
2167
2546
  /**
@@ -2174,10 +2553,7 @@ var CameraGridAddon = class extends BaseAddon {
2174
2553
  if (!entry.session.isRunning()) continue;
2175
2554
  this.ctx.logger.info("camera grid: the layout changed - restarting the composition", {
2176
2555
  tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
2177
- meta: {
2178
- instanceId: entry.instanceId,
2179
- profile: entry.profile
2180
- }
2556
+ meta: { instanceId: entry.instanceId }
2181
2557
  });
2182
2558
  entry.session.onChildEnded("layout-changed");
2183
2559
  }
@@ -2212,24 +2588,15 @@ var CameraGridAddon = class extends BaseAddon {
2212
2588
  descriptorsFor(instanceId) {
2213
2589
  const instance = this.instanceById(instanceId);
2214
2590
  const relay = this.relay;
2215
- const offered = this.offeredProfiles(instanceId);
2216
- if (!instance || !relay || offered === null) return null;
2217
- return offered.map((profile) => {
2218
- const canvas = gridCanvasFor({
2219
- width: instance.width,
2220
- height: instance.height
2221
- }, profile);
2222
- return gridStreamDescriptor({
2223
- profile,
2224
- width: canvas.width,
2225
- height: canvas.height,
2226
- fps: instance.fps,
2227
- url: relay.streamUrl({
2228
- instanceId,
2229
- profile
2230
- })
2231
- });
2232
- });
2591
+ const plan = this.planFor(instanceId);
2592
+ if (!instance || !relay || plan === null) return null;
2593
+ if (!plan.offered) return [];
2594
+ return [gridStreamDescriptor({
2595
+ width: instance.width,
2596
+ height: instance.height,
2597
+ fps: instance.fps,
2598
+ url: relay.streamUrl({ instanceId })
2599
+ })];
2233
2600
  }
2234
2601
  instanceIdForDevice(deviceId) {
2235
2602
  for (const [instanceId, id] of this.deviceIdByInstanceId) if (id === deviceId) return instanceId;
@@ -2254,11 +2621,15 @@ var CameraGridAddon = class extends BaseAddon {
2254
2621
  source: cell.source,
2255
2622
  cell: cell.cell
2256
2623
  })),
2257
- profiles: (this.offersByInstanceId.get(instance.id) ?? []).map((offer) => ({
2258
- profile: offer.profile,
2259
- offered: offer.offered,
2260
- missingSources: offer.offered ? [] : [...offer.missingSources]
2261
- }))
2624
+ profiles: (() => {
2625
+ const plan = this.planByInstanceId.get(instance.id);
2626
+ if (plan === void 0) return [];
2627
+ return [{
2628
+ profile: GRID_OUTPUT_PROFILE,
2629
+ offered: plan.offered,
2630
+ missingSources: [...plan.missingSources]
2631
+ }];
2632
+ })()
2262
2633
  };
2263
2634
  }
2264
2635
  async saveGridLayout(input) {
@@ -2284,7 +2655,7 @@ var CameraGridAddon = class extends BaseAddon {
2284
2655
  ...row,
2285
2656
  ...updated
2286
2657
  } : row) });
2287
- await this.refreshProfileOffers(this.instances());
2658
+ await this.refreshSourcePlans(this.instances());
2288
2659
  this.ensureSessions(instance.id, input.deviceId);
2289
2660
  const view = this.gridViewFor(input.deviceId);
2290
2661
  if (!view) throw new Error("camera-grid: the grid vanished while it was being saved");
@@ -2350,4 +2721,4 @@ var CameraGridAddon = class extends BaseAddon {
2350
2721
  }
2351
2722
  };
2352
2723
  //#endregion
2353
- export { CameraGridAddon, CameraGridAddon as default, GRID_CAM_STREAM_ID, GRID_PROFILES, GRID_SILENCED_CAP_NAMES, GridCameraDevice, GridFrameSampler, GridLastFrameFileStore, GridSnapshotSource, GridStreamRelay, GridStreamSession, buildGridCameraDeclarations, buildGridFilterGraph, gridCamStreamId, gridCameraReconciliationIndex, gridCanvasFor, gridPlanFor, gridSnapshotAnswerFor, gridSourceDial, gridStreamDescriptor, gridStreamInvocation, installGridCameraRuntime, newGridCameraStableId, normalizeGridRows, offeredGridProfiles, parseGridStreamPath, readGridInstances, silenceAnalysisFor, startGridChild };
2724
+ export { CameraGridAddon, CameraGridAddon as default, GRID_CAM_STREAM_ID, GRID_OUTPUT_PROFILE, GRID_SILENCED_CAP_NAMES, GridCameraDevice, GridFrameSampler, GridLastFrameFileStore, GridSnapshotSource, GridStreamRelay, GridStreamSession, buildGridCameraDeclarations, buildGridFilterGraph, gridCameraReconciliationIndex, gridPlanFor, gridSnapshotAnswerFor, gridSourceDial, gridSourceProfiles, gridStreamDescriptor, gridStreamInvocation, installGridCameraRuntime, lowestServedProfile, newGridCameraStableId, normalizeGridRows, parseGridStreamPath, readGridInstances, silenceAnalysisFor, startGridChild };