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