@camstack/system 1.2.261 → 1.2.263

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.
@@ -595,82 +595,50 @@ var GridLastFrameFileStore = class {
595
595
  //#endregion
596
596
  //#region src/builtins/camera-grid/grid-profiles.ts
597
597
  /**
598
- * One id, for every grid, forever — for the HIGH composition.
598
+ * One id, for every grid, forever.
599
599
  *
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.
600
+ * D519's reason is what keeps this id bare: a per-instance or newly-spelled id
601
+ * mints a second cam-stream and orphans whatever profile assignment the
602
+ * operator had made. It was `high` on the live hub before the per-profile
603
+ * detour and it is `high` again, so nothing an operator assigned is disturbed.
604
604
  */
605
605
  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
- /**
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
606
  /**
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.
607
+ * The slot the single composition claims.
608
+ *
609
+ * NOT decoration. A device publishing one stream with no hint is ranked by
610
+ * pixel count and lands in `mid` — the slot `recordings` does not select on its
611
+ * own, that `recordingsLow` ignores, and that scrub never reads. The Dreame
612
+ * robot's single 720p relay landed exactly there and its footage was written
613
+ * where nothing looked.
625
614
  */
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}`;
615
+ var GRID_OUTPUT_PROFILE = "high";
616
+ /** Cheapest first — the order a source is read in. */
617
+ var CHEAPEST_FIRST = [
618
+ "low",
619
+ "mid",
620
+ "high"
621
+ ];
622
+ /** The cheapest stream this source serves, or `null` when it serves none. */
623
+ function lowestServedProfile(served) {
624
+ return CHEAPEST_FIRST.find((profile) => served.includes(profile)) ?? null;
631
625
  }
632
- /** Even, floored, scaled by the profile. libx264 refuses an odd dimension. */
633
- function gridCanvasFor(canvas, profile) {
634
- const scale = CANVAS_SCALE[profile];
626
+ /** Which stream to read each source at, and who cannot be read at all. */
627
+ function gridSourceProfiles(input) {
628
+ const profileBySource = /* @__PURE__ */ new Map();
629
+ const missingSources = [];
630
+ for (const deviceId of input.sourceDeviceIds) {
631
+ const served = input.profilesServedBy(deviceId);
632
+ const chosen = served === null ? null : lowestServedProfile(served);
633
+ if (chosen === null) missingSources.push(deviceId);
634
+ else profileBySource.set(deviceId, chosen);
635
+ }
635
636
  return {
636
- width: evenAtLeast(canvas.width * scale, MIN_CANVAS_WIDTH),
637
- height: evenAtLeast(canvas.height * scale, MIN_CANVAS_HEIGHT)
637
+ offered: input.sourceDeviceIds.length > 0 && missingSources.length === 0,
638
+ profileBySource,
639
+ missingSources
638
640
  };
639
641
  }
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
642
  //#endregion
675
643
  //#region src/builtins/camera-grid/grid-sentry-sources.ts
676
644
  function gridSourceDial(acquired) {
@@ -689,6 +657,43 @@ function gridSourceDial(acquired) {
689
657
  //#endregion
690
658
  //#region src/builtins/camera-grid/grid-snapshot.ts
691
659
  /**
660
+ * A grid's snapshot — a frame of the COMPOSITION, never a dial.
661
+ *
662
+ * ## The decision, and the two things it rejects
663
+ *
664
+ * The operator: *"Lo snapshot possiamo sempre farlo dallo stream sempre, non
665
+ * c'e' bisogno di comporre gli snapshots"*. So:
666
+ *
667
+ * - **Not composed from source snapshots.** That would be a SECOND compositor,
668
+ * with its own geometry, its own aspect handling and its own bugs, producing
669
+ * a picture that differs from the real one in ways nobody would ever notice
670
+ * until they mattered.
671
+ * - **Not produced by starting the composition.** The whole of D519 is "a grid
672
+ * costs nothing when nobody watches". A thumbnail that dials N cameras — and
673
+ * on a battery camera, wakes one — spends the entire budget of the feature on
674
+ * a 160-pixel-wide picture in a list.
675
+ *
676
+ * ## The three states, all answered
677
+ *
678
+ * | state | answer |
679
+ * | --- | --- |
680
+ * | a composition is running | one frame sampled out of the bytes it is already emitting |
681
+ * | nothing running, a frame was kept | that frame — stale, and honest about it |
682
+ * | nothing running, nothing ever kept | `null`, logged with the reason |
683
+ *
684
+ * `null` is the courtesy card, which is the correct picture of "this grid has
685
+ * never been watched". What it must never be is a reason to dial.
686
+ *
687
+ * ## Why this is a NATIVE snapshot provider
688
+ *
689
+ * The wrapper's ladder is native-first: a native that produces a frame
690
+ * short-circuits before the broker frame-grab, and the broker frame-grab is the
691
+ * thing that would wake the sources. A grid with a stored frame or a live
692
+ * composition therefore never reaches it. The residual — a grid that has never
693
+ * once been composed — is documented in D519; it cannot wake a battery camera,
694
+ * because a grid never dials one live at all (`grid-sentry-sources.ts`).
695
+ */
696
+ /**
692
697
  * Which of the three answers applies. Pure, so the rule can be read — and
693
698
  * sabotaged — without a socket, a file or an ffmpeg.
694
699
  */
@@ -721,10 +726,7 @@ var GridSnapshotSource = class {
721
726
  return toImage(sampled);
722
727
  }
723
728
  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
- });
729
+ this.deps.logger.warn("camera grid: the composition is running but produced no frame, and none was kept", { tags: { deviceId } });
728
730
  return null;
729
731
  }
730
732
  if (answer.kind === "stored" && stored !== null) return toImage(stored);
@@ -732,7 +734,7 @@ var GridSnapshotSource = class {
732
734
  tags: { deviceId },
733
735
  meta: {
734
736
  reason: "never-composed",
735
- profiles: sessions.length
737
+ compositions: sessions.length
736
738
  }
737
739
  });
738
740
  return null;
@@ -741,18 +743,12 @@ var GridSnapshotSource = class {
741
743
  try {
742
744
  const jpeg = await this.deps.sampleFrame(session);
743
745
  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
- });
746
+ this.deps.logger.warn("camera grid: could not sample a frame from the live composition", { tags: { deviceId } });
748
747
  return null;
749
748
  } catch (error) {
750
749
  this.deps.logger.warn("camera grid: could not sample a frame from the live composition", {
751
750
  tags: { deviceId },
752
- meta: {
753
- profile: session.profile,
754
- error: errMsg(error)
755
- }
751
+ meta: { error: errMsg(error) }
756
752
  });
757
753
  return null;
758
754
  }
@@ -806,24 +802,15 @@ var PATH_PREFIX = "/grid/";
806
802
  var PATH_SUFFIX = GRID_WIRE_PATH_SUFFIX;
807
803
  /** How long a reader may refuse to drain before it is cut. */
808
804
  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. */
805
+ /** `/grid/<instanceId>.flv`, or `null` when it is anything else. */
813
806
  function parseGridStreamPath(url) {
814
807
  const path = (url ?? "").split("?")[0] ?? "";
815
808
  if (!path.startsWith(PATH_PREFIX) || !path.endsWith(PATH_SUFFIX)) return null;
816
809
  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;
810
+ if (raw.length === 0 || raw.includes("/")) return null;
821
811
  try {
822
- const instanceId = decodeURIComponent(raw.slice(0, at));
823
- return instanceId.length > 0 ? {
824
- instanceId,
825
- profile
826
- } : null;
812
+ const instanceId = decodeURIComponent(raw);
813
+ return instanceId.length > 0 ? { instanceId } : null;
827
814
  } catch {
828
815
  return null;
829
816
  }
@@ -862,16 +849,13 @@ var GridStreamRelay = class {
862
849
  streamUrl(request) {
863
850
  if (!this.baseUrl) throw new Error("camera-grid: the stream relay is not started");
864
851
  const instance = encodeURIComponent(request.instanceId);
865
- return `${this.baseUrl}${PATH_PREFIX}${instance}/${request.profile}${PATH_SUFFIX}`;
852
+ return `${this.baseUrl}${PATH_PREFIX}${instance}${PATH_SUFFIX}`;
866
853
  }
867
854
  async serve(request, res) {
868
- const { instanceId, profile } = request;
855
+ const { instanceId } = request;
869
856
  const session = this.deps.sessionFor(request);
870
857
  if (session === null) {
871
- this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
872
- instanceId,
873
- profile
874
- } });
858
+ this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: { instanceId } });
875
859
  res.writeHead(404).end();
876
860
  return;
877
861
  }
@@ -905,7 +889,6 @@ var GridStreamRelay = class {
905
889
  tags: { deviceId: session.deviceId },
906
890
  meta: {
907
891
  instanceId,
908
- profile,
909
892
  stalledForMs: SLOW_READER_MS
910
893
  }
911
894
  });
@@ -918,7 +901,6 @@ var GridStreamRelay = class {
918
901
  tags: { deviceId: session.deviceId },
919
902
  meta: {
920
903
  instanceId,
921
- profile,
922
904
  reason
923
905
  }
924
906
  });
@@ -931,7 +913,6 @@ var GridStreamRelay = class {
931
913
  tags: { deviceId: session.deviceId },
932
914
  meta: {
933
915
  instanceId,
934
- profile,
935
916
  error: errMsg(error)
936
917
  }
937
918
  });
@@ -967,13 +948,13 @@ var GridStreamRelay = class {
967
948
  * scrub never reads. The Dreame robot's one 720p relay landed exactly there and
968
949
  * its footage was written nowhere anything looked.
969
950
  *
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.
951
+ * So the single composition claims `high` EXPLICITLY (`GRID_OUTPUT_PROFILE`).
952
+ * It is also the slot this stream held before the per-profile detour, which is
953
+ * what keeps an operator's existing assignment intact.
973
954
  */
974
955
  function gridStreamDescriptor(input) {
975
956
  return {
976
- camStreamId: gridCamStreamId(input.profile),
957
+ camStreamId: GRID_CAM_STREAM_ID,
977
958
  kind: GRID_WIRE_STREAM_KIND,
978
959
  url: input.url,
979
960
  codec: "h264",
@@ -982,8 +963,8 @@ function gridStreamDescriptor(input) {
982
963
  height: input.height
983
964
  },
984
965
  fps: input.fps,
985
- label: `Grid composition (${input.profile})`,
986
- profileHint: input.profile,
966
+ label: "Grid composition",
967
+ profileHint: GRID_OUTPUT_PROFILE,
987
968
  autoEligible: true
988
969
  };
989
970
  }
@@ -1582,16 +1563,58 @@ var PIXEL_FORMAT = "yuv420p";
1582
1563
  */
1583
1564
  var GOP_SECONDS = 1;
1584
1565
  /**
1585
- * `-analyzeduration` / `-probesize`, well under ffmpeg's 5 s / 5 MB defaults.
1566
+ * `-analyzeduration` / `-probesize`, as small as ffmpeg allows.
1586
1567
  *
1587
- * Safe on RTSP specifically: the SDP already declares the codec, so the probe
1588
- * has nothing to discover. It matters more here than anywhere else in the repo
1589
- * because the probes are PARALLEL inputs of one child — the composite emits
1590
- * nothing until the LAST of them has finished, so the default budget is paid
1591
- * once per grid, at the slowest source's pace.
1568
+ * ## The probes are SEQUENTIAL, and this is what the tile skew was
1569
+ *
1570
+ * The sentence that used to be here said the probes are "PARALLEL inputs of one
1571
+ * child". They are not. ffmpeg opens its inputs one after another and each open
1572
+ * BLOCKS in `avformat_find_stream_info` until its budget is spent — so input 0
1573
+ * is already connected and receiving while ffmpeg is still opening input 1, its
1574
+ * frames pile up, and `overlay` (which pairs inputs BY PTS) marries input 0's
1575
+ * oldest frame to input 1's newest. The lag is fixed for the life of the child:
1576
+ * both tiles then advance one frame per output frame, so nothing ever closes it.
1577
+ * The FIRST cell is the most behind; the last is live.
1578
+ *
1579
+ * Reproduced in one command on 2026-09-18 — two sources hstacked, one frame,
1580
+ * reading the cameras' own burnt-in clocks out of the composed picture:
1581
+ *
1582
+ * ```
1583
+ * 13:11:21 | 13:11:23 two seconds, and the earlier input is the late one
1584
+ * ```
1585
+ *
1586
+ * ## Why the old numbers cost seconds
1587
+ *
1588
+ * `-probesize` is a BYTE budget, and 1 MB of a 640x360 sub-stream is tens of
1589
+ * seconds of video — so `-analyzeduration 1s` never got the chance to stop it
1590
+ * and each open ran until the byte budget filled. Zero and 32 make the probe
1591
+ * return from the SDP, which already declares the codec, without reading media.
1592
+ *
1593
+ * Measured on this hub, the two sources of the live grid, three runs each,
1594
+ * production input args otherwise identical — time to the first composed frame,
1595
+ * and the skew read off the two burnt-in clocks in that frame:
1596
+ *
1597
+ * ```
1598
+ * 1 s / 1 MB 8986, 9728, 9712 ms skew 5 s, 5 s, 4 s
1599
+ * 0 / 32 2281, 2270, 2304 ms skew 1 s, 1 s, 0 s
1600
+ * ```
1601
+ *
1602
+ * The same numbers are the ~9 s cold start the operator had accepted: it was a
1603
+ * SUM of the opens, never the wait for the slowest source.
1604
+ *
1605
+ * Checked before shipping, because a probe that reads nothing has to survive
1606
+ * what it is not told: h264, h265 and a derived (`mid-to-low`) source all open
1607
+ * with `rc=0`, a 20 s run produces 200 frames of 200 either way (so the pacing
1608
+ * this file already lost once is untouched), and the skew is still 0 at 25 s
1609
+ * into a run — it is set at start-up and does not drift.
1610
+ *
1611
+ * What remains is the spread in when each source's first key frame arrives, and
1612
+ * a `/muted` dial waits for the next IDR. That spread is small here; if it ever
1613
+ * is not, the answer is to start the inputs from a join point we hold, NOT to
1614
+ * re-time the inputs (see `useWallclockTimestamps` below).
1592
1615
  */
1593
- var ANALYZE_DURATION_US = 1e6;
1594
- var PROBE_SIZE_BYTES = 1e6;
1616
+ var ANALYZE_DURATION_US = 0;
1617
+ var PROBE_SIZE_BYTES = 32;
1595
1618
  function inputPlanFor(source) {
1596
1619
  return {
1597
1620
  url: source.url,
@@ -1719,21 +1742,18 @@ var DEFAULTS = {
1719
1742
  cameraGrids: [],
1720
1743
  cameraGridTombstones: []
1721
1744
  };
1722
- /** One session per (grid, profile). A grid composes each profile separately. */
1723
- function sessionKey(instanceId, profile) {
1724
- return `${instanceId}::${profile}`;
1725
- }
1726
1745
  /**
1727
- * One grid's composition of one profile, and enough about it for the snapshot
1728
- * to choose between them without reaching into the session.
1746
+ * One grid's composition — there is exactly one, keyed by the grid itself.
1747
+ *
1748
+ * It used to be one per profile, and the key carried the profile. That shape
1749
+ * is what made a viewer's adaptive step tear a composition down and build
1750
+ * another; see `grid-profiles.ts` for the log that showed it.
1729
1751
  */
1730
1752
  var GridSessionEntry = class {
1731
1753
  instanceId;
1732
- profile;
1733
1754
  session;
1734
- constructor(instanceId, profile, session) {
1755
+ constructor(instanceId, session) {
1735
1756
  this.instanceId = instanceId;
1736
- this.profile = profile;
1737
1757
  this.session = session;
1738
1758
  }
1739
1759
  isRunning() {
@@ -1743,7 +1763,7 @@ var GridSessionEntry = class {
1743
1763
  var CameraGridAddon = class extends BaseAddon {
1744
1764
  relay = null;
1745
1765
  reconcileTimer = null;
1746
- /** Keyed by {@link sessionKey} — one composition per grid per profile. */
1766
+ /** Keyed by instance id — ONE composition per grid. */
1747
1767
  sessions = /* @__PURE__ */ new Map();
1748
1768
  deviceIdByInstanceId = /* @__PURE__ */ new Map();
1749
1769
  tombstones = /* @__PURE__ */ new Set();
@@ -1757,7 +1777,7 @@ var CameraGridAddon = class extends BaseAddon {
1757
1777
  * (D224), and 60 s is the window for a fact — "does 615 have a low assigned"
1758
1778
  * — that only changes when an operator changes it.
1759
1779
  */
1760
- offersByInstanceId = /* @__PURE__ */ new Map();
1780
+ planByInstanceId = /* @__PURE__ */ new Map();
1761
1781
  snapshots = null;
1762
1782
  frameSampler = null;
1763
1783
  ffmpegBinaryPath = "ffmpeg";
@@ -1860,7 +1880,7 @@ var CameraGridAddon = class extends BaseAddon {
1860
1880
  if (!this.relay) return;
1861
1881
  const instances = this.instances();
1862
1882
  const declarations = buildGridCameraDeclarations(instances);
1863
- await this.refreshProfileOffers(instances);
1883
+ await this.refreshSourcePlans(instances);
1864
1884
  await this.retireWithdrawnSessions(instances);
1865
1885
  const result = await new DeclaredDevices({
1866
1886
  logger: this.ctx.logger.child("declaration"),
@@ -1927,15 +1947,15 @@ var CameraGridAddon = class extends BaseAddon {
1927
1947
  }
1928
1948
  }
1929
1949
  /**
1930
- * Which profiles each grid can honestly publish, asked of the BROKER.
1950
+ * Which stream each grid reads each of its sources at, asked of the BROKER.
1931
1951
  *
1932
1952
  * `listAllProfileSlots` is the one authority on "does 615 have a low
1933
1953
  * assigned", and `sourceCamStreamId === null` is its way of saying no. A read
1934
- * that FAILS leaves the previous answer in place and says so - it is not an
1954
+ * that FAILS leaves the previous answer in place and says so — it is not an
1935
1955
  * empty catalog, and turning it into one would retract the streams of every
1936
1956
  * grid on the hub.
1937
1957
  */
1938
- async refreshProfileOffers(instances) {
1958
+ async refreshSourcePlans(instances) {
1939
1959
  let slots;
1940
1960
  try {
1941
1961
  slots = await this.ctx.api.streamBroker.listAllProfileSlots.query();
@@ -1954,69 +1974,70 @@ var CameraGridAddon = class extends BaseAddon {
1954
1974
  servedBy.set(slot.deviceId, profiles);
1955
1975
  }
1956
1976
  for (const instance of instances) {
1957
- const offers = offeredGridProfiles({
1977
+ const plan = gridSourceProfiles({
1958
1978
  sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))],
1959
1979
  profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
1960
1980
  });
1961
- const previous = this.offersByInstanceId.get(instance.id);
1962
- this.offersByInstanceId.set(instance.id, offers);
1963
- this.logRefusals(instance, offers, previous);
1981
+ const previous = this.planByInstanceId.get(instance.id);
1982
+ this.planByInstanceId.set(instance.id, plan);
1983
+ this.logSourcePlan(instance, plan, previous);
1964
1984
  }
1965
1985
  }
1966
1986
  /**
1967
- * Say which profiles this grid will NOT publish, and why - once per change.
1987
+ * Say WHICH stream each cell is read at, and who could not be read - once per
1988
+ * change.
1989
+ *
1990
+ * Both halves earn their line. A grid silently missing from a catalog is
1991
+ * indistinguishable from a broker that swept it, so the refusal names the
1992
+ * sources. And the chosen profiles are the answer to "why is this tile
1993
+ * soft?" — a camera whose only assigned slot is `high` is read at `high`, and
1994
+ * nothing else in the system would ever say so.
1968
1995
  *
1969
- * A profile silently missing from a catalog is indistinguishable from a
1970
- * broker that swept it. Logged on the EDGE rather than every minute: a grid
1971
- * that can only serve `high` would otherwise write two lines a minute for
1972
- * ever.
1996
+ * Logged on the EDGE: a grid that cannot be composed would otherwise write a
1997
+ * line a minute for ever.
1973
1998
  */
1974
- logRefusals(instance, offers, previous) {
1975
- if (previous !== void 0 && JSON.stringify(previous) === JSON.stringify(offers)) return;
1999
+ logSourcePlan(instance, plan, previous) {
2000
+ const shape = (value) => JSON.stringify([
2001
+ value.offered,
2002
+ [...value.profileBySource],
2003
+ value.missingSources
2004
+ ]);
2005
+ if (previous !== void 0 && shape(previous) === shape(plan)) return;
1976
2006
  const deviceId = this.deviceIdByInstanceId.get(instance.id) ?? 0;
1977
- for (const offer of offers) {
1978
- if (offer.offered) continue;
1979
- if (offer.missingSources.length === 0) continue;
1980
- this.ctx.logger.info("camera grid: a profile is NOT on offer", {
1981
- tags: { deviceId },
1982
- meta: {
1983
- instanceId: instance.id,
1984
- profile: offer.profile,
1985
- missingSources: [...offer.missingSources]
1986
- }
1987
- });
1988
- }
1989
- this.ctx.logger.info("camera grid: published profiles", {
2007
+ if (plan.missingSources.length > 0) this.ctx.logger.info("camera grid: a source cannot be read — the grid is NOT on offer", {
1990
2008
  tags: { deviceId },
1991
2009
  meta: {
1992
2010
  instanceId: instance.id,
1993
- offered: offers.filter((offer) => offer.offered).map((offer) => offer.profile)
2011
+ missingSources: [...plan.missingSources]
2012
+ }
2013
+ });
2014
+ this.ctx.logger.info("camera grid: composing", {
2015
+ tags: { deviceId },
2016
+ meta: {
2017
+ instanceId: instance.id,
2018
+ offered: plan.offered,
2019
+ readAt: Object.fromEntries([...plan.profileBySource].map(([id, p]) => [String(id), p]))
1994
2020
  }
1995
2021
  });
1996
2022
  }
1997
- offeredProfiles(instanceId) {
1998
- const offers = this.offersByInstanceId.get(instanceId);
1999
- if (offers === void 0) return null;
2000
- return offers.filter((offer) => offer.offered).map((offer) => offer.profile);
2023
+ planFor(instanceId) {
2024
+ return this.planByInstanceId.get(instanceId) ?? null;
2001
2025
  }
2002
2026
  ensureSessions(instanceId, deviceId) {
2003
- const offered = this.offeredProfiles(instanceId) ?? [];
2004
- for (const profile of GRID_PROFILES) {
2005
- const key = sessionKey(instanceId, profile);
2006
- if (!offered.includes(profile)) {
2007
- const stale = this.sessions.get(key);
2008
- if (stale) {
2009
- this.sessions.delete(key);
2010
- stale.session.dispose();
2011
- }
2012
- continue;
2027
+ const plan = this.planFor(instanceId);
2028
+ if (plan === null || !plan.offered) {
2029
+ const stale = this.sessions.get(instanceId);
2030
+ if (stale) {
2031
+ this.sessions.delete(instanceId);
2032
+ stale.session.dispose();
2013
2033
  }
2014
- if (this.sessions.has(key)) continue;
2015
- const session = this.makeSession(instanceId, deviceId, profile);
2016
- this.sessions.set(key, new GridSessionEntry(instanceId, profile, session));
2034
+ return;
2017
2035
  }
2036
+ if (this.sessions.has(instanceId)) return;
2037
+ const session = this.makeSession(instanceId, deviceId);
2038
+ this.sessions.set(instanceId, new GridSessionEntry(instanceId, session));
2018
2039
  }
2019
- makeSession(instanceId, deviceId, profile) {
2040
+ makeSession(instanceId, deviceId) {
2020
2041
  const childContext = {
2021
2042
  binaryPath: this.ffmpegBinaryPath,
2022
2043
  logger: this.ctx.logger,
@@ -2027,6 +2048,8 @@ var CameraGridAddon = class extends BaseAddon {
2027
2048
  deps: {
2028
2049
  logger: this.ctx.logger,
2029
2050
  acquireSource: async (sourceDeviceId) => {
2051
+ const profile = this.planFor(instanceId)?.profileBySource.get(sourceDeviceId);
2052
+ if (profile === void 0) throw new Error(`camera-grid: no stream is assigned for source ${String(sourceDeviceId)}`);
2030
2053
  const dial = gridSourceDial(await acquireWithRetry(() => this.ctx.api.streamBroker.getStreamWithCodec.mutate({
2031
2054
  deviceId: sourceDeviceId,
2032
2055
  video: "copy",
@@ -2076,11 +2099,7 @@ var CameraGridAddon = class extends BaseAddon {
2076
2099
  if (!instance) return null;
2077
2100
  return gridPlanFor(instance, {
2078
2101
  encoder: "libx264",
2079
- decodeHwAccel: null,
2080
- canvas: gridCanvasFor({
2081
- width: instance.width,
2082
- height: instance.height
2083
- }, profile)
2102
+ decodeHwAccel: null
2084
2103
  });
2085
2104
  }
2086
2105
  });
@@ -2088,18 +2107,14 @@ var CameraGridAddon = class extends BaseAddon {
2088
2107
  }
2089
2108
  /** The session a dial is asking for, or `null` - which the relay answers 404 to. */
2090
2109
  sessionFor(request) {
2091
- return this.sessions.get(sessionKey(request.instanceId, request.profile))?.session ?? null;
2110
+ return this.sessions.get(request.instanceId)?.session ?? null;
2092
2111
  }
2093
- /** This grid's sessions, in DESCENDING quality - the snapshot takes the first running one. */
2112
+ /** This grid's composition, as the list the snapshot source expects. */
2094
2113
  sessionsForDevice(deviceId) {
2095
2114
  const instanceId = this.instanceIdForDevice(deviceId);
2096
2115
  if (instanceId === null) return [];
2097
- const entries = [];
2098
- for (const profile of GRID_PROFILES) {
2099
- const entry = this.sessions.get(sessionKey(instanceId, profile));
2100
- if (entry) entries.push(entry);
2101
- }
2102
- return entries;
2116
+ const entry = this.sessions.get(instanceId);
2117
+ return entry ? [entry] : [];
2103
2118
  }
2104
2119
  /** A grid that is gone stops immediately - it must not keep N sources dialled. */
2105
2120
  async retireWithdrawnSessions(instances) {
@@ -2108,10 +2123,7 @@ var CameraGridAddon = class extends BaseAddon {
2108
2123
  if (live.has(entry.instanceId)) continue;
2109
2124
  this.ctx.logger.info("camera grid: retiring a withdrawn grid", {
2110
2125
  tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
2111
- meta: {
2112
- instanceId: entry.instanceId,
2113
- profile: entry.profile
2114
- }
2126
+ meta: { instanceId: entry.instanceId }
2115
2127
  });
2116
2128
  this.sessions.delete(key);
2117
2129
  await entry.session.dispose();
@@ -2119,7 +2131,7 @@ var CameraGridAddon = class extends BaseAddon {
2119
2131
  for (const instanceId of [...this.deviceIdByInstanceId.keys()]) {
2120
2132
  if (live.has(instanceId)) continue;
2121
2133
  this.deviceIdByInstanceId.delete(instanceId);
2122
- this.offersByInstanceId.delete(instanceId);
2134
+ this.planByInstanceId.delete(instanceId);
2123
2135
  }
2124
2136
  }
2125
2137
  /**
@@ -2132,10 +2144,7 @@ var CameraGridAddon = class extends BaseAddon {
2132
2144
  if (!entry.session.isRunning()) continue;
2133
2145
  this.ctx.logger.info("camera grid: the layout changed - restarting the composition", {
2134
2146
  tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
2135
- meta: {
2136
- instanceId: entry.instanceId,
2137
- profile: entry.profile
2138
- }
2147
+ meta: { instanceId: entry.instanceId }
2139
2148
  });
2140
2149
  entry.session.onChildEnded("layout-changed");
2141
2150
  }
@@ -2170,24 +2179,15 @@ var CameraGridAddon = class extends BaseAddon {
2170
2179
  descriptorsFor(instanceId) {
2171
2180
  const instance = this.instanceById(instanceId);
2172
2181
  const relay = this.relay;
2173
- const offered = this.offeredProfiles(instanceId);
2174
- if (!instance || !relay || offered === null) return null;
2175
- return offered.map((profile) => {
2176
- const canvas = gridCanvasFor({
2177
- width: instance.width,
2178
- height: instance.height
2179
- }, profile);
2180
- return gridStreamDescriptor({
2181
- profile,
2182
- width: canvas.width,
2183
- height: canvas.height,
2184
- fps: instance.fps,
2185
- url: relay.streamUrl({
2186
- instanceId,
2187
- profile
2188
- })
2189
- });
2190
- });
2182
+ const plan = this.planFor(instanceId);
2183
+ if (!instance || !relay || plan === null) return null;
2184
+ if (!plan.offered) return [];
2185
+ return [gridStreamDescriptor({
2186
+ width: instance.width,
2187
+ height: instance.height,
2188
+ fps: instance.fps,
2189
+ url: relay.streamUrl({ instanceId })
2190
+ })];
2191
2191
  }
2192
2192
  instanceIdForDevice(deviceId) {
2193
2193
  for (const [instanceId, id] of this.deviceIdByInstanceId) if (id === deviceId) return instanceId;
@@ -2212,11 +2212,15 @@ var CameraGridAddon = class extends BaseAddon {
2212
2212
  source: cell.source,
2213
2213
  cell: cell.cell
2214
2214
  })),
2215
- profiles: (this.offersByInstanceId.get(instance.id) ?? []).map((offer) => ({
2216
- profile: offer.profile,
2217
- offered: offer.offered,
2218
- missingSources: offer.offered ? [] : [...offer.missingSources]
2219
- }))
2215
+ profiles: (() => {
2216
+ const plan = this.planByInstanceId.get(instance.id);
2217
+ if (plan === void 0) return [];
2218
+ return [{
2219
+ profile: GRID_OUTPUT_PROFILE,
2220
+ offered: plan.offered,
2221
+ missingSources: [...plan.missingSources]
2222
+ }];
2223
+ })()
2220
2224
  };
2221
2225
  }
2222
2226
  async saveGridLayout(input) {
@@ -2242,7 +2246,7 @@ var CameraGridAddon = class extends BaseAddon {
2242
2246
  ...row,
2243
2247
  ...updated
2244
2248
  } : row) });
2245
- await this.refreshProfileOffers(this.instances());
2249
+ await this.refreshSourcePlans(this.instances());
2246
2250
  this.ensureSessions(instance.id, input.deviceId);
2247
2251
  const view = this.gridViewFor(input.deviceId);
2248
2252
  if (!view) throw new Error("camera-grid: the grid vanished while it was being saved");
@@ -2308,4 +2312,4 @@ var CameraGridAddon = class extends BaseAddon {
2308
2312
  }
2309
2313
  };
2310
2314
  //#endregion
2311
- 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 };
2315
+ 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 };