@camstack/system 1.2.264 → 1.2.266

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 (59) 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/grid-compositor-child.d.ts +3 -0
  12. package/dist/builtins/camera-grid/grid-compositor-invocation.d.ts +19 -0
  13. package/dist/builtins/camera-grid/grid-compositor.d.ts +2 -2
  14. package/dist/builtins/camera-grid/grid-tile-plan.d.ts +8 -9
  15. package/dist/builtins/camera-grid/index.js +781 -212
  16. package/dist/builtins/camera-grid/index.mjs +781 -212
  17. package/dist/builtins/console-logging/index.js +1 -1
  18. package/dist/builtins/console-logging/index.mjs +1 -1
  19. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  20. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  21. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  22. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  23. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  24. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  25. package/dist/builtins/hub-forwarder/index.js +1 -1
  26. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  27. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  28. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  29. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  30. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  31. package/dist/builtins/local-network/local-network.addon.js +1 -1
  32. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  33. package/dist/builtins/loki-logging/index.js +1 -1
  34. package/dist/builtins/loki-logging/index.mjs +1 -1
  35. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  36. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  37. package/dist/builtins/platform-probe/index.js +1 -1
  38. package/dist/builtins/platform-probe/index.mjs +1 -1
  39. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  40. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  41. package/dist/builtins/snapshot/index.js +1 -1
  42. package/dist/builtins/snapshot/index.mjs +1 -1
  43. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  44. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  46. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  47. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  48. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  49. package/dist/builtins/system-config/system-config.addon.js +1 -1
  50. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  51. package/dist/builtins/winston-logging/index.js +1 -1
  52. package/dist/builtins/winston-logging/index.mjs +1 -1
  53. package/dist/{dist-BhYweLVr.js → dist-BbPssgI6.js} +12 -1
  54. package/dist/{dist-CO2O_ddo.mjs → dist-Ck0rjYOQ.mjs} +12 -1
  55. package/dist/index.js +1 -1
  56. package/dist/index.mjs +1 -1
  57. package/dist/{retired-settings-keys-BnnHWyfj.mjs → retired-settings-keys-BZpDuJVh.mjs} +1 -1
  58. package/dist/{retired-settings-keys-CKLI8VmE.js → retired-settings-keys-Dg_69cOY.js} +1 -1
  59. package/package.json +1 -1
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BhYweLVr.js");
6
+ const require_dist = require("../../dist-BbPssgI6.js");
7
7
  let zod = require("zod");
8
8
  let _camstack_types_node = require("@camstack/types/node");
9
9
  let node_path = require("node:path");
@@ -1800,196 +1800,373 @@ async function silenceAnalysisFor(deps, deviceId) {
1800
1800
  if (failures.length > 0) throw new Error(`camera grid ${deviceId}: could not switch off ${failures.length} analyzer(s) — it will run at full detection cost (${failures.join("; ")})`);
1801
1801
  }
1802
1802
  //#endregion
1803
- //#region src/builtins/camera-grid/grid-filter-graph.ts
1804
- var CANVAS_LABEL = "canvas";
1805
- var OUT_LABEL = "grid";
1806
- /** Full-frame within a tolerance, i.e. nothing to crop. */
1807
- var FULL_FRAME_EPSILON = 1e-6;
1808
- function isFullFrame(rect) {
1809
- return Math.abs(rect.x) < FULL_FRAME_EPSILON && Math.abs(rect.y) < FULL_FRAME_EPSILON && Math.abs(rect.width - 1) < FULL_FRAME_EPSILON && Math.abs(rect.height - 1) < FULL_FRAME_EPSILON;
1810
- }
1811
- /**
1812
- * yuv420p subsamples chroma 2x2, so an odd width or height is rejected by every
1813
- * encoder we use. Rounding here lets the reason be stated; rounding inside the
1814
- * child is a start-up failure with no context.
1815
- */
1816
- function toEven(value) {
1817
- const rounded = Math.round(value);
1818
- const even = rounded % 2 === 0 ? rounded : rounded - 1;
1819
- return Math.max(2, even);
1820
- }
1821
- function assertWithin(rect, what, cellIndex) {
1822
- if (rect.x < 0 || rect.y < 0 || rect.width <= 0 || rect.height <= 0 || rect.x + rect.width > 1.000001 || rect.y + rect.height > 1.000001) throw new Error(`camera-grid: cell ${cellIndex} falls ${what} (x=${rect.x} y=${rect.y} w=${rect.width} h=${rect.height}) — ffmpeg would render this as a silently clipped picture`);
1803
+ //#region src/builtins/camera-grid/grid-canvas.ts
1804
+ /** Bytes one yuv420p frame of this size occupies. */
1805
+ function yuv420pSize(width, height) {
1806
+ return width * height + 2 * (width / 2) * (height / 2);
1823
1807
  }
1824
1808
  /**
1825
- * `crop` resolved against the source's OWN size at runtime (`iw`/`ih`), never
1826
- * against a resolution guessed here. That is what makes the stored rectangle
1827
- * survive a source that changes resolution.
1809
+ * Round DOWN to even.
1810
+ *
1811
+ * Down and not nearest: a tile that rounded UP would claim a row of samples the
1812
+ * decoder was never asked to produce, and the copy would either overrun or be
1813
+ * refused at the moment the picture is wanted.
1828
1814
  */
1829
- function cropExpression(source) {
1830
- return `crop=iw*${source.width}:ih*${source.height}:iw*${source.x}:ih*${source.y}`;
1815
+ function evenDown(value) {
1816
+ return Math.max(0, Math.floor(value / 2) * 2);
1831
1817
  }
1832
- function buildGridFilterGraph(layout) {
1833
- if (layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell to emit anything");
1834
- const steps = [`color=c=black:s=${layout.width}x${layout.height}:d=1[${CANVAS_LABEL}]`];
1835
- const cellLabels = [];
1836
- layout.cells.forEach((cell, index) => {
1837
- assertWithin(cell.source, "outside its source", index);
1838
- assertWithin(cell.cell, "outside the canvas", index);
1839
- const targetWidth = toEven(cell.cell.width * layout.width);
1840
- const targetHeight = toEven(cell.cell.height * layout.height);
1841
- const label = `c${index}`;
1842
- const filters = [...isFullFrame(cell.source) ? [] : [cropExpression(cell.source)], `scale=${targetWidth}:${targetHeight}`];
1843
- steps.push(`[${cell.inputIndex}:v]${filters.join(",")}[${label}]`);
1844
- cellLabels.push(label);
1845
- });
1846
- let base = CANVAS_LABEL;
1847
- layout.cells.forEach((cell, index) => {
1848
- const x = Math.round(cell.cell.x * layout.width);
1849
- const y = Math.round(cell.cell.y * layout.height);
1850
- const out = index === layout.cells.length - 1 ? OUT_LABEL : `s${index}`;
1851
- steps.push(`[${base}][${cellLabels[index]}]overlay=${x}:${y}[${out}]`);
1852
- base = out;
1853
- });
1854
- return {
1855
- graph: steps.join(";"),
1856
- videoOutLabel: OUT_LABEL
1857
- };
1818
+ var GridCanvas = class {
1819
+ width;
1820
+ height;
1821
+ buffer;
1822
+ lumaBytes;
1823
+ chromaBytes;
1824
+ constructor(width, height) {
1825
+ this.width = width;
1826
+ this.height = height;
1827
+ this.lumaBytes = width * height;
1828
+ this.chromaBytes = width / 2 * (height / 2);
1829
+ this.buffer = Buffer.alloc(yuv420pSize(width, height));
1830
+ this.clear();
1831
+ }
1832
+ /**
1833
+ * Legible black — Y 0, chroma 128 (neutral).
1834
+ *
1835
+ * `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
1836
+ * strong green. A cell nobody has filled yet has to look like an empty cell,
1837
+ * not like a fault.
1838
+ */
1839
+ clear() {
1840
+ this.buffer.fill(0, 0, this.lumaBytes);
1841
+ this.buffer.fill(128, this.lumaBytes);
1842
+ }
1843
+ /**
1844
+ * Copy one decoded tile in at `origin`.
1845
+ *
1846
+ * Returns false — never throws, never writes a partial tile — when the tile
1847
+ * does not fit, is the wrong size for the dimensions it claims, or sits on an
1848
+ * odd boundary. This runs on the output tick, so a bad frame from one source
1849
+ * must cost that source's rectangle and nothing else.
1850
+ */
1851
+ blit(tile, size, origin) {
1852
+ if (!isEven(size.width) || !isEven(size.height)) return false;
1853
+ if (!isEven(origin.x) || !isEven(origin.y)) return false;
1854
+ if (size.width <= 0 || size.height <= 0) return false;
1855
+ if (origin.x < 0 || origin.y < 0) return false;
1856
+ if (origin.x + size.width > this.width) return false;
1857
+ if (origin.y + size.height > this.height) return false;
1858
+ if (tile.length !== yuv420pSize(size.width, size.height)) return false;
1859
+ for (let row = 0; row < size.height; row += 1) {
1860
+ const from = row * size.width;
1861
+ const to = (origin.y + row) * this.width + origin.x;
1862
+ tile.copy(this.buffer, to, from, from + size.width);
1863
+ }
1864
+ const tileLuma = size.width * size.height;
1865
+ const tileChroma = size.width / 2 * (size.height / 2);
1866
+ const halfW = size.width / 2;
1867
+ const halfH = size.height / 2;
1868
+ const canvasHalfW = this.width / 2;
1869
+ for (let plane = 0; plane < 2; plane += 1) {
1870
+ const tileBase = tileLuma + plane * tileChroma;
1871
+ const canvasBase = this.lumaBytes + plane * this.chromaBytes;
1872
+ for (let row = 0; row < halfH; row += 1) {
1873
+ const from = tileBase + row * halfW;
1874
+ const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
1875
+ tile.copy(this.buffer, to, from, from + halfW);
1876
+ }
1877
+ }
1878
+ return true;
1879
+ }
1880
+ };
1881
+ function isEven(value) {
1882
+ return Number.isInteger(value) && value % 2 === 0;
1858
1883
  }
1859
1884
  //#endregion
1860
- //#region src/builtins/camera-grid/grid-stream-invocation.ts
1861
- /**
1862
- * The encoder preset. `veryfast` because a composite is the one encode in the
1863
- * cluster whose input cost already scales with the number of cameras in it —
1864
- * the decode side of a 2x2 is four decodes — so the encode side is where a
1865
- * cheap default matters most.
1866
- */
1867
- var PRESET = "veryfast";
1885
+ //#region src/builtins/camera-grid/grid-tile-feed.ts
1868
1886
  /**
1869
- * `-tune zerolatency`, and this one is worth several SECONDS.
1887
+ * One source's decoded tiles, of which only the LATEST is ever wanted.
1870
1888
  *
1871
- * x264's defaults hold frames before emitting any: a rate-control lookahead of
1872
- * ~40 frames plus B-frames. At a composite's 10 fps that lookahead alone is
1873
- * about four seconds of picture sitting inside the encoder — the operator saw
1874
- * it as "ritardi di secondi" and it was not the network, the relay or the
1875
- * broker. `zerolatency` sets `rc-lookahead=0`, `sync-lookahead=0` and
1876
- * `bframes=0`, which is exactly the trade a live composite wants: a few percent
1877
- * more bitrate for the same picture, in exchange for the encoder emitting each
1878
- * frame as it arrives.
1889
+ * ## Why dropping is the point, not a compromise
1879
1890
  *
1880
- * It is not a preset. `veryfast` says how hard the encoder searches; this says
1881
- * how long it is allowed to WAIT, and the two are independent.
1882
- */
1883
- var TUNE = "zerolatency";
1884
- /** yuv420p: the only pixel format every consumer of a CamStack profile reads. */
1885
- var PIXEL_FORMAT = "yuv420p";
1886
- /**
1887
- * Key-frame cadence, in seconds.
1891
+ * ffmpeg's `overlay` consumes one frame per input per output frame, so an input
1892
+ * that got ahead of another stays ahead for the life of the child — that is the
1893
+ * tile gap. A canvas asks a different question: "what does this camera look
1894
+ * like NOW". Everything behind the newest frame is not a backlog to work
1895
+ * through, it is history, and dropping it is what keeps every tile at the same
1896
+ * moment however unevenly their frames arrive.
1888
1897
  *
1889
- * Two things ride on this and they pull the same way. A recorder cuts its
1890
- * segments on a key frame, so a long GOP produces long segments and a scrub
1891
- * that lands far from where the operator clicked. And every consumer — the
1892
- * broker's reader included — starts at a key frame, so the GOP is the WORST
1893
- * CASE wait before a grid appears at all: at two seconds a viewer could stare
1894
- * at nothing for two seconds after pressing play.
1898
+ * It is also the only bound on memory here. A decoder child that outruns the
1899
+ * output tick — or a tick that stalls — would otherwise grow an unbounded queue
1900
+ * of raw frames, and a raw frame is 345 KB at 640x360.
1895
1901
  *
1896
- * One second halves that wait. It costs bitrate (an IDR is expensive and there
1897
- * are now twice as many), which is why it is not lower: below a second the
1898
- * bitrate paid buys a wait nobody can feel.
1902
+ * ## The last frame is HELD
1903
+ *
1904
+ * A tick that finds nothing new redraws what was there. A tile must not blink
1905
+ * because a source was 40 ms late, and a source that has genuinely stopped is a
1906
+ * still picture — which is legible, and which the snapshot path already treats
1907
+ * as the honest answer for a camera that is not sending.
1899
1908
  */
1900
- var GOP_SECONDS = 1;
1909
+ var GridTileFeed = class {
1910
+ frameBytes;
1911
+ partial = Buffer.alloc(0);
1912
+ newest = null;
1913
+ fresh = false;
1914
+ constructor(frameBytes) {
1915
+ this.frameBytes = frameBytes;
1916
+ }
1917
+ /** Feed bytes from the decoder. Any whole frames inside replace the newest. */
1918
+ push(chunk) {
1919
+ let buf = this.partial.length === 0 ? chunk : Buffer.concat([this.partial, chunk]);
1920
+ while (buf.length >= this.frameBytes) {
1921
+ this.newest = Buffer.from(buf.subarray(0, this.frameBytes));
1922
+ this.fresh = true;
1923
+ buf = buf.subarray(this.frameBytes);
1924
+ }
1925
+ this.partial = buf.length === 0 ? Buffer.alloc(0) : Buffer.from(buf);
1926
+ }
1927
+ /** The newest whole frame, or `null` while none has arrived. */
1928
+ latest() {
1929
+ this.fresh = false;
1930
+ return this.newest;
1931
+ }
1932
+ /** Has a new frame arrived since the last {@link latest}? */
1933
+ hasFresh() {
1934
+ return this.fresh;
1935
+ }
1936
+ /** For the bound in the spec: one frame plus at most a partial one. */
1937
+ bufferedBytes() {
1938
+ return (this.newest?.length ?? 0) + this.partial.length;
1939
+ }
1940
+ };
1941
+ //#endregion
1942
+ //#region src/builtins/camera-grid/grid-compositor.ts
1901
1943
  /**
1902
- * `-analyzeduration` / `-probesize`, as small as ffmpeg allows.
1903
- *
1904
- * ## The probes are SEQUENTIAL, and this is what the tile skew was
1905
- *
1906
- * The sentence that used to be here said the probes are "PARALLEL inputs of one
1907
- * child". They are not. ffmpeg opens its inputs one after another and each open
1908
- * BLOCKS in `avformat_find_stream_info` until its budget is spent — so input 0
1909
- * is already connected and receiving while ffmpeg is still opening input 1, its
1910
- * frames pile up, and `overlay` (which pairs inputs BY PTS) marries input 0's
1911
- * oldest frame to input 1's newest. The lag is fixed for the life of the child:
1912
- * both tiles then advance one frame per output frame, so nothing ever closes it.
1913
- * The FIRST cell is the most behind; the last is live.
1914
- *
1915
- * Reproduced in one command on 2026-09-18 — two sources hstacked, one frame,
1916
- * reading the cameras' own burnt-in clocks out of the composed picture:
1917
- *
1918
- * ```
1919
- * 13:11:21 | 13:11:23 two seconds, and the earlier input is the late one
1920
- * ```
1921
- *
1922
- * ## Why the old numbers cost seconds
1923
- *
1924
- * `-probesize` is a BYTE budget, and 1 MB of a 640x360 sub-stream is tens of
1925
- * seconds of video — so `-analyzeduration 1s` never got the chance to stop it
1926
- * and each open ran until the byte budget filled. Zero and 32 make the probe
1927
- * return from the SDP, which already declares the codec, without reading media.
1928
- *
1929
- * Measured on this hub, the two sources of the live grid, three runs each,
1930
- * production input args otherwise identical — time to the first composed frame,
1931
- * and the skew read off the two burnt-in clocks in that frame:
1932
- *
1933
- * ```
1934
- * 1 s / 1 MB 8986, 9728, 9712 ms skew 5 s, 5 s, 4 s
1935
- * 0 / 32 2281, 2270, 2304 ms skew 1 s, 1 s, 0 s
1936
- * ```
1937
- *
1938
- * The same numbers are the ~9 s cold start the operator had accepted: it was a
1939
- * SUM of the opens, never the wait for the slowest source.
1940
- *
1941
- * Checked before shipping, because a probe that reads nothing has to survive
1942
- * what it is not told: h264, h265 and a derived (`mid-to-low`) source all open
1943
- * with `rc=0`, a 20 s run produces 200 frames of 200 either way (so the pacing
1944
- * this file already lost once is untouched), and the skew is still 0 at 25 s
1945
- * into a run — it is set at start-up and does not drift.
1946
- *
1947
- * What remains is the spread in when each source's first key frame arrives, and
1948
- * a `/muted` dial waits for the next IDR. That spread is small here; if it ever
1949
- * is not, the answer is to start the inputs from a join point we hold, NOT to
1950
- * re-time the inputs (see `useWallclockTimestamps` below).
1944
+ * The composition, driven by the OUTPUT's clock.
1945
+ *
1946
+ * ## What this replaces, and why it is the only thing that works
1947
+ *
1948
+ * `overlay` pairs its inputs BY PTS and ffmpeg gives each input pts 0 at its
1949
+ * own first frame, so two sources that joined at different moments compose
1950
+ * different moments for the life of the child. Measured at 4-5 s on this hub,
1951
+ * drifting over hours as the sources' key-frame phases slide through each other.
1952
+ * Every cheaper fix was tried and measured: `-use_wallclock_as_timestamps`
1953
+ * re-times by arrival and wrecked the pacing; `-copyts` changes nothing because
1954
+ * the broker re-bases each session (both restreams report `pts_time:0`), so
1955
+ * there is no shared timeline to align against; and serving each dial from the
1956
+ * newest key frame it already holds only moves the offsets into the past,
1957
+ * because those key frames are of different ages.
1958
+ *
1959
+ * Here there is nothing to pair. Every tick asks each source "what do you look
1960
+ * like NOW" and the answer is whatever arrived last. The gap is zero by
1961
+ * construction rather than by tuning.
1962
+ *
1963
+ * ## It also removes the wait
1964
+ *
1965
+ * A cell with no frame yet is BLACK, not a reason to hold the picture back. The
1966
+ * output exists from the first tick and cells fill in as their sources arrive —
1967
+ * instead of the whole grid waiting for the slowest source's first key frame,
1968
+ * which is what the ~9 s cold start was.
1969
+ *
1970
+ * ## What it costs
1971
+ *
1972
+ * The same N decodes ffmpeg already paid, in N children instead of one, plus
1973
+ * one strided memcpy per tile per tick (`grid-canvas.ts`). It does NOT reuse the
1974
+ * frames the detection pipeline decodes: those live in per-session decode
1975
+ * workers behind two process boundaries, the shm plane that once joined them was
1976
+ * removed on 2026-07-16, and what the pipeline retains is a model-sized view
1977
+ * (320x180 JPEG, on demand), not a tile.
1951
1978
  */
1952
- var ANALYZE_DURATION_US = 0;
1953
- var PROBE_SIZE_BYTES = 32;
1954
- function inputPlanFor(source) {
1955
- return {
1956
- url: source.url,
1957
- rtspTransport: "tcp",
1958
- fflags: ["+discardcorrupt"],
1959
- lowDelay: true,
1960
- analyzeDurationUs: ANALYZE_DURATION_US,
1961
- probeSizeBytes: PROBE_SIZE_BYTES
1962
- };
1979
+ var GridCompositor = class {
1980
+ deps;
1981
+ canvas;
1982
+ /** Keyed by tile id — unique per CELL, because one camera can hold two. */
1983
+ placed = /* @__PURE__ */ new Map();
1984
+ closed = false;
1985
+ constructor(deps) {
1986
+ this.deps = deps;
1987
+ this.canvas = new GridCanvas(deps.plan.canvas.width, deps.plan.canvas.height);
1988
+ for (const tile of deps.plan.tiles) this.placed.set(tile.id, {
1989
+ tile,
1990
+ feed: new GridTileFeed(tile.frameBytes)
1991
+ });
1992
+ }
1993
+ /**
1994
+ * Raw bytes from one decoder output.
1995
+ *
1996
+ * A tile this composition has no rectangle for is IGNORED, never thrown on: a
1997
+ * child that outlives a layout change would otherwise take the tick down with
1998
+ * it, and the tick is what every other cell depends on.
1999
+ */
2000
+ onTile(tileId, chunk) {
2001
+ this.placed.get(tileId)?.feed.push(chunk);
2002
+ }
2003
+ /**
2004
+ * Compose and emit one frame. Returns what the sink said about back-pressure.
2005
+ *
2006
+ * Every tile is redrawn, fresh or not: a tile that produced nothing this tick
2007
+ * is a still picture, not a hole, and clearing it would make a late source
2008
+ * flicker. The canvas is never cleared between ticks for the same reason.
2009
+ */
2010
+ tick() {
2011
+ if (this.closed) return true;
2012
+ for (const { tile, feed } of this.placed.values()) {
2013
+ const frame = feed.latest();
2014
+ if (frame === null) continue;
2015
+ this.canvas.blit(frame, tile.size, tile.origin);
2016
+ }
2017
+ return this.deps.write(this.canvas.buffer);
2018
+ }
2019
+ /** After this, a tick emits nothing — a dead child must not keep writing. */
2020
+ close() {
2021
+ this.closed = true;
2022
+ }
2023
+ };
2024
+ //#endregion
2025
+ //#region src/builtins/camera-grid/grid-tile-plan.ts
2026
+ /**
2027
+ * The operator's layout, as one decoder child per CELL.
2028
+ *
2029
+ * ## Why per cell, when `grid-plan.ts` dedupes per source
2030
+ *
2031
+ * The single ffmpeg that this replaces made a camera used by two cells ONE
2032
+ * input, because decoding it twice doubles the expensive half of the job. A
2033
+ * compositor cannot do that with the same shape: this repo has exactly one
2034
+ * ffmpeg argv builder (`scripts/check-ffmpeg-primitive.ts`, Rule 1) and it
2035
+ * expresses one output per process, so a source feeding two differently cropped
2036
+ * and differently scaled tiles needs two children.
2037
+ *
2038
+ * The trade is small and worth stating: since D528 every source is read at its
2039
+ * CHEAPEST stream, so the duplicate is a second 640x360 sub-stream decode, not a
2040
+ * second 4 MP one. Teaching the builder several outputs would buy that back, and
2041
+ * is the right change to make if a grid ever shows one camera many times — it is
2042
+ * deliberately not made for a case that costs this little.
2043
+ *
2044
+ * ## Why the tile is scaled in the DECODER
2045
+ *
2046
+ * ffmpeg's scaler is SIMD and already there. Scaling in this process would be a
2047
+ * per-pixel loop in JavaScript, on the event loop that also answers the grid's
2048
+ * RPCs. Handing each child the exact pixel size its rectangle needs makes the
2049
+ * composition a strided memcpy (`grid-canvas.ts`) and keeps the only per-pixel
2050
+ * work where it belongs.
2051
+ *
2052
+ * ## Even, and snapped DOWN
2053
+ *
2054
+ * yuv420p subsamples chroma 2x2, so an odd origin lands a tile's chroma half a
2055
+ * sample off its luma — which TINTS the tile instead of breaking it, and
2056
+ * survives review. Sizes round DOWN so a tile never claims a row the decoder
2057
+ * was not asked to produce; a cell that rounds away to nothing is dropped
2058
+ * rather than emitted at zero size, because a zero-size output is an ffmpeg
2059
+ * start-up failure that would take the whole grid with it.
2060
+ */
2061
+ /** Smallest tile worth decoding. Below this it is not a cheaper picture, it is none. */
2062
+ var MIN_TILE_PX = 2;
2063
+ /** The one pixel format the canvas is in, so a blit is a copy and not a convert. */
2064
+ var TILE_PIXEL_FORMAT = "yuv420p";
2065
+ var FULL_FRAME_EPSILON$1 = 1e-6;
2066
+ function isFullFrame$1(rect) {
2067
+ return Math.abs(rect.x) < FULL_FRAME_EPSILON$1 && Math.abs(rect.y) < FULL_FRAME_EPSILON$1 && Math.abs(rect.width - 1) < FULL_FRAME_EPSILON$1 && Math.abs(rect.height - 1) < FULL_FRAME_EPSILON$1;
1963
2068
  }
1964
2069
  /**
1965
- * @throws when the layout addresses an input the sources do not contain, or
1966
- * when there is nothing to compose. Both are plan errors: ffmpeg would refuse
1967
- * them at start-up with a message about a filter pad, which names the graph
1968
- * rather than the configuration that produced it.
2070
+ * `crop` resolved against the source's OWN size at runtime (`iw`/`ih`), never
2071
+ * against a resolution guessed here — which is what lets a stored rectangle
2072
+ * survive a camera that changes resolution under us.
1969
2073
  */
1970
- function gridStreamInvocation(input) {
1971
- if (input.sources.length === 0 || input.layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell with a source before it can emit anything");
1972
- for (const cell of input.layout.cells) if (cell.inputIndex < 0 || cell.inputIndex >= input.sources.length) throw new Error(`camera-grid: cell addresses input ordinal ${cell.inputIndex}, but only ${input.sources.length} input(s) were acquired`);
1973
- const graph = buildGridFilterGraph(input.layout);
1974
- const [first, ...rest] = input.sources;
1975
- if (first === void 0) throw new Error("camera-grid: a composite needs at least one cell with a source");
2074
+ function cropExpression$1(source) {
2075
+ return `crop=iw*${source.width}:ih*${source.height}:iw*${source.x}:ih*${source.y}`;
2076
+ }
2077
+ function buildGridTilePlan(input) {
2078
+ const tiles = [];
2079
+ for (const [index, cell] of input.cells.entries()) {
2080
+ const width = evenDown(cell.cell.width * input.canvas.width);
2081
+ const height = evenDown(cell.cell.height * input.canvas.height);
2082
+ if (width < MIN_TILE_PX || height < MIN_TILE_PX) continue;
2083
+ const x = Math.min(evenDown(cell.cell.x * input.canvas.width), evenDown(input.canvas.width - width));
2084
+ const y = Math.min(evenDown(cell.cell.y * input.canvas.height), evenDown(input.canvas.height - height));
2085
+ tiles.push({
2086
+ id: `cell-${String(index)}`,
2087
+ deviceId: cell.deviceId,
2088
+ size: {
2089
+ width,
2090
+ height
2091
+ },
2092
+ origin: {
2093
+ x,
2094
+ y
2095
+ },
2096
+ frameBytes: yuv420pSize(width, height),
2097
+ filter: [
2098
+ ...isFullFrame$1(cell.source) ? [] : [cropExpression$1(cell.source)],
2099
+ `scale=${String(width)}:${String(height)}`,
2100
+ `format=${TILE_PIXEL_FORMAT}`
2101
+ ].join(",")
2102
+ });
2103
+ }
2104
+ return {
2105
+ canvas: input.canvas,
2106
+ tiles
2107
+ };
2108
+ }
2109
+ //#endregion
2110
+ //#region src/builtins/camera-grid/grid-compositor-invocation.ts
2111
+ /** See D527: the probe returns from the SDP instead of reading media. */
2112
+ var ANALYZE_DURATION_US$1 = 0;
2113
+ var PROBE_SIZE_BYTES$1 = 32;
2114
+ var PRESET$1 = "veryfast";
2115
+ var TUNE$1 = "zerolatency";
2116
+ var GOP_SECONDS$1 = 1;
2117
+ /** One tile's decoder: dial in, raw yuv420p frames out. */
2118
+ function gridTileDecoderInvocation(input) {
2119
+ return {
2120
+ logLevel: "error",
2121
+ decodeHwAccel: null,
2122
+ input: {
2123
+ url: input.url,
2124
+ rtspTransport: "tcp",
2125
+ fflags: ["+discardcorrupt"],
2126
+ lowDelay: true,
2127
+ analyzeDurationUs: ANALYZE_DURATION_US$1,
2128
+ probeSizeBytes: PROBE_SIZE_BYTES$1
2129
+ },
2130
+ video: {
2131
+ kind: "raw",
2132
+ pixelFormat: TILE_PIXEL_FORMAT,
2133
+ filter: input.tile.filter,
2134
+ fps: input.fps
2135
+ },
2136
+ audio: { kind: "none" },
2137
+ threadCount: 0,
2138
+ outputArgs: [],
2139
+ sink: {
2140
+ kind: "stdout",
2141
+ container: "rawvideo"
2142
+ }
2143
+ };
2144
+ }
2145
+ /** The canvas encoder: raw frames on stdin, one compressed stream out. */
2146
+ function gridCanvasEncoderInvocation(input) {
1976
2147
  const video = {
1977
2148
  kind: "encode",
1978
2149
  encoder: input.encoder,
1979
- preset: PRESET,
1980
- tune: TUNE,
1981
- pixelFormat: PIXEL_FORMAT,
2150
+ preset: PRESET$1,
2151
+ tune: TUNE$1,
2152
+ pixelFormat: TILE_PIXEL_FORMAT,
1982
2153
  fps: input.fps,
1983
- gopFrames: input.fps * GOP_SECONDS,
2154
+ gopFrames: input.fps * GOP_SECONDS$1,
1984
2155
  bitrateKbps: input.bitrateKbps,
1985
2156
  scale: null
1986
2157
  };
1987
2158
  return {
1988
2159
  logLevel: "error",
1989
- decodeHwAccel: input.decodeHwAccel,
1990
- input: inputPlanFor(first),
1991
- extraInputs: rest.map(inputPlanFor),
1992
- filterGraph: graph,
2160
+ decodeHwAccel: null,
2161
+ input: {
2162
+ url: "pipe:0",
2163
+ rawVideo: {
2164
+ pixelFormat: TILE_PIXEL_FORMAT,
2165
+ width: input.canvas.width,
2166
+ height: input.canvas.height,
2167
+ framerate: input.fps
2168
+ }
2169
+ },
1993
2170
  video,
1994
2171
  audio: { kind: "none" },
1995
2172
  threadCount: 0,
@@ -2001,66 +2178,194 @@ function gridStreamInvocation(input) {
2001
2178
  };
2002
2179
  }
2003
2180
  //#endregion
2004
- //#region src/builtins/camera-grid/grid-child.ts
2181
+ //#region src/builtins/camera-grid/grid-compositor-child.ts
2005
2182
  /**
2006
- * Spawning the composition, through the ONE primitive.
2183
+ * The composition as processes: one decoder per tile, one encoder, one tick.
2007
2184
  *
2008
- * `FfmpegProcess` (`@camstack/types`) owns spawn, the first-data deadline, the
2009
- * hardware→software retry, exit classification and the bounded restart. This
2010
- * file owns only the PLUMBING — which bytes go where — because that is the one
2011
- * thing the primitive deliberately does not own.
2185
+ * It presents the SAME shape the single ffmpeg did — `{ stdout, stop }` — so
2186
+ * `GridStreamSession` is untouched. The seam is deliberate: the session owns
2187
+ * demand, acquisition and teardown, and none of that changes because the
2188
+ * picture is assembled differently.
2012
2189
  *
2013
- * `maxRestarts: 0`. A composite is demand-driven: if the child dies, the
2014
- * session tells every consumer why and tears down, and the consumer's next read
2015
- * starts a cold one with freshly acquired sources. A restart inside the process
2016
- * would reuse broker handles that may already have been released.
2190
+ * ## The order things start in, and why it is the order
2191
+ *
2192
+ * The encoder first, then the tick, then the decoders. The tick writes a black
2193
+ * canvas from its very first beat, so the encoder produces a stream — and the
2194
+ * consumer sees a picture — before any camera has delivered anything. That is
2195
+ * the cold start the old shape spent waiting for the slowest source's first key
2196
+ * frame, and it is now spent watching cells fill in.
2197
+ *
2198
+ * ## Back-pressure is a SKIPPED tick, never a queue
2199
+ *
2200
+ * A raw canvas frame is 3.1 MB at 1080p. If the encoder stops draining, the one
2201
+ * safe thing is to stop composing until it drains again: a queue of raw frames
2202
+ * is how a stalled encoder becomes an OOM, and a dropped frame in a live
2203
+ * composite costs nothing anybody can see.
2204
+ *
2205
+ * ## Any child exiting ends the composition
2206
+ *
2207
+ * Same policy as the single child (`maxRestarts: 0`): the session tells its
2208
+ * consumers why and tears down, and the next read starts a cold composition
2209
+ * with freshly acquired sources. Leaving a dead tile frozen while the others
2210
+ * run would be a nicer picture and a worse fact — the operator would be looking
2211
+ * at a camera that stopped minutes ago with nothing saying so.
2017
2212
  */
2018
- /** No first-data deadline retry loop: one attempt, and the session hears about it. */
2019
- var FIRST_DATA_TIMEOUT_MS = 15e3;
2020
- async function startGridChild(ctx, plan, sources, onEnded) {
2021
- const invocation = (decodeHwAccel) => gridStreamInvocation({
2022
- layout: plan.layout,
2023
- sources: plan.sourceDeviceIds.map((deviceId, index) => ({
2024
- deviceId,
2025
- url: sources[index]?.url ?? ""
2026
- })),
2027
- decodeHwAccel,
2028
- encoder: plan.encoder,
2029
- fps: plan.fps,
2030
- bitrateKbps: plan.bitrateKbps
2213
+ /**
2214
+ * The encoder must produce its first bytes quickly, because the tick feeds it a
2215
+ * black canvas immediately — a silence here is the encoder failing to start,
2216
+ * not a camera being slow.
2217
+ */
2218
+ var ENCODER_FIRST_DATA_MS = 5e3;
2219
+ /**
2220
+ * A decoder's first frame waits for its source's next key frame, which on this
2221
+ * fleet is up to a GOP. Generous, and it costs only that tile: the picture is
2222
+ * already on screen.
2223
+ */
2224
+ var DECODER_FIRST_DATA_MS = 15e3;
2225
+ async function startGridCompositorChild(ctx, plan, sources, onEnded) {
2226
+ const tilePlan = buildGridTilePlan({
2227
+ canvas: {
2228
+ width: plan.layout.width,
2229
+ height: plan.layout.height
2230
+ },
2231
+ cells: plan.layout.cells.map((cell) => ({
2232
+ deviceId: plan.sourceDeviceIds[cell.inputIndex] ?? 0,
2233
+ source: cell.source,
2234
+ cell: cell.cell
2235
+ }))
2031
2236
  });
2032
- let stdout = null;
2033
- const process = new _camstack_types_node.FfmpegProcess({
2237
+ if (tilePlan.tiles.length === 0) throw new Error("camera-grid: every cell rounded away to nothing — there is no picture to make");
2238
+ let ended = false;
2239
+ const end = (reason) => {
2240
+ if (ended) return;
2241
+ ended = true;
2242
+ onEnded(reason);
2243
+ };
2244
+ let encoderIn = null;
2245
+ let encoderOut = null;
2246
+ let draining = false;
2247
+ let tick = null;
2248
+ const compositor = new GridCompositor({
2249
+ plan: tilePlan,
2250
+ write: (frame) => {
2251
+ const sink = encoderIn;
2252
+ if (sink === null || draining || sink.destroyed) return false;
2253
+ const accepted = sink.write(frame);
2254
+ if (!accepted) draining = true;
2255
+ return accepted;
2256
+ }
2257
+ });
2258
+ const startTick = () => {
2259
+ if (tick !== null) return;
2260
+ tick = setInterval(() => {
2261
+ compositor.tick();
2262
+ }, Math.max(1, Math.round(1e3 / plan.fps)));
2263
+ tick.unref();
2264
+ };
2265
+ const encoder = new _camstack_types_node.FfmpegProcess({
2034
2266
  binaryPath: ctx.binaryPath,
2035
- buildArgs: (decodeHwAccel) => require_dist.buildFfmpegArgs(invocation(decodeHwAccel)),
2036
- decodeHwAccel: plan.decodeHwAccel,
2037
- logger: ctx.logger,
2267
+ buildArgs: () => require_dist.buildFfmpegArgs(gridCanvasEncoderInvocation({
2268
+ canvas: tilePlan.canvas,
2269
+ fps: plan.fps,
2270
+ encoder: plan.encoder,
2271
+ bitrateKbps: plan.bitrateKbps
2272
+ })),
2273
+ decodeHwAccel: null,
2274
+ logger: ctx.logger.child("encode"),
2038
2275
  deviceId: ctx.deviceId,
2039
- role: "camera-grid",
2040
- tags: {
2041
- cells: plan.layout.cells.length,
2042
- inputs: plan.sourceDeviceIds.length
2043
- },
2044
- firstDataTimeoutMs: FIRST_DATA_TIMEOUT_MS,
2276
+ role: "camera-grid-encode",
2277
+ tags: { tiles: tilePlan.tiles.length },
2278
+ firstDataTimeoutMs: ENCODER_FIRST_DATA_MS,
2045
2279
  maxRestarts: 0,
2280
+ stdio: [
2281
+ "pipe",
2282
+ "pipe",
2283
+ "pipe"
2284
+ ],
2046
2285
  onChild: (child) => {
2047
- if (child.stdout) stdout = child.stdout;
2286
+ encoderIn = child.stdin;
2287
+ if (child.stdout) encoderOut = child.stdout;
2288
+ child.stdin?.on("drain", () => {
2289
+ draining = false;
2290
+ });
2291
+ startTick();
2048
2292
  child.stderr?.on("data", () => {});
2293
+ child.stdin?.on("error", () => {});
2049
2294
  },
2050
- onExit: (exit) => {
2051
- onEnded(exit.classification);
2295
+ onExit: (exit) => end(`encoder:${exit.classification}`)
2296
+ });
2297
+ try {
2298
+ await encoder.start();
2299
+ } catch (error) {
2300
+ if (tick !== null) clearInterval(tick);
2301
+ compositor.close();
2302
+ throw error;
2303
+ }
2304
+ if (encoderIn === null || encoderOut === null) {
2305
+ await encoder.stop();
2306
+ throw new Error("camera-grid: the canvas encoder exposed no stdin/stdout to compose into");
2307
+ }
2308
+ const urlByDeviceId = /* @__PURE__ */ new Map();
2309
+ for (const [index, deviceId] of plan.sourceDeviceIds.entries()) {
2310
+ const url = sources[index]?.url;
2311
+ if (url !== void 0) urlByDeviceId.set(deviceId, url);
2312
+ }
2313
+ const decoders = [];
2314
+ const startDecoder = async (tile) => {
2315
+ const url = urlByDeviceId.get(tile.deviceId);
2316
+ if (url === void 0) throw new Error(`camera-grid: no acquired source for device ${String(tile.deviceId)}`);
2317
+ const decoder = new _camstack_types_node.FfmpegProcess({
2318
+ binaryPath: ctx.binaryPath,
2319
+ buildArgs: () => require_dist.buildFfmpegArgs(gridTileDecoderInvocation({
2320
+ tile,
2321
+ url,
2322
+ fps: plan.fps
2323
+ })),
2324
+ decodeHwAccel: null,
2325
+ logger: ctx.logger.child("tile"),
2326
+ deviceId: tile.deviceId,
2327
+ role: "camera-grid-tile",
2328
+ tags: {
2329
+ tile: tile.id,
2330
+ width: tile.size.width,
2331
+ height: tile.size.height
2332
+ },
2333
+ firstDataTimeoutMs: DECODER_FIRST_DATA_MS,
2334
+ maxRestarts: 0,
2335
+ onChild: (child) => {
2336
+ child.stdout?.on("data", (chunk) => {
2337
+ compositor.onTile(tile.id, chunk);
2338
+ });
2339
+ child.stderr?.on("data", () => {});
2340
+ },
2341
+ onExit: (exit) => end(`tile ${tile.id}:${exit.classification}`)
2342
+ });
2343
+ decoders.push(decoder);
2344
+ await decoder.start();
2345
+ };
2346
+ const stopAll = async () => {
2347
+ if (tick !== null) clearInterval(tick);
2348
+ compositor.close();
2349
+ await Promise.allSettled(decoders.map((d) => d.stop()));
2350
+ await encoder.stop();
2351
+ };
2352
+ try {
2353
+ for (const tile of tilePlan.tiles) await startDecoder(tile);
2354
+ } catch (error) {
2355
+ await stopAll();
2356
+ throw error;
2357
+ }
2358
+ ctx.logger.info("camera grid: composing on an output clock", {
2359
+ tags: { deviceId: ctx.deviceId },
2360
+ meta: {
2361
+ tiles: tilePlan.tiles.length,
2362
+ canvas: `${String(tilePlan.canvas.width)}x${String(tilePlan.canvas.height)}`,
2363
+ fps: plan.fps
2052
2364
  }
2053
2365
  });
2054
- await process.start();
2055
- if (stdout === null) {
2056
- await process.stop();
2057
- throw new Error("camera-grid: the composition child produced no stdout to read");
2058
- }
2059
2366
  return {
2060
- stdout,
2061
- stop: async () => {
2062
- await process.stop();
2063
- }
2367
+ stdout: encoderOut,
2368
+ stop: stopAll
2064
2369
  };
2065
2370
  }
2066
2371
  //#endregion
@@ -2503,7 +2808,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2503
2808
  releaseSource: async (pipelineKey) => {
2504
2809
  await this.ctx.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey });
2505
2810
  },
2506
- startChild: async (plan, sources) => startGridChild(childContext, plan, sources, (reason) => {
2811
+ startChild: async (plan, sources) => startGridCompositorChild(childContext, plan, sources, (reason) => {
2507
2812
  session.onChildEnded(reason);
2508
2813
  })
2509
2814
  },
@@ -2726,6 +3031,270 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2726
3031
  }
2727
3032
  };
2728
3033
  //#endregion
3034
+ //#region src/builtins/camera-grid/grid-filter-graph.ts
3035
+ var CANVAS_LABEL = "canvas";
3036
+ var OUT_LABEL = "grid";
3037
+ /** Full-frame within a tolerance, i.e. nothing to crop. */
3038
+ var FULL_FRAME_EPSILON = 1e-6;
3039
+ function isFullFrame(rect) {
3040
+ return Math.abs(rect.x) < FULL_FRAME_EPSILON && Math.abs(rect.y) < FULL_FRAME_EPSILON && Math.abs(rect.width - 1) < FULL_FRAME_EPSILON && Math.abs(rect.height - 1) < FULL_FRAME_EPSILON;
3041
+ }
3042
+ /**
3043
+ * yuv420p subsamples chroma 2x2, so an odd width or height is rejected by every
3044
+ * encoder we use. Rounding here lets the reason be stated; rounding inside the
3045
+ * child is a start-up failure with no context.
3046
+ */
3047
+ function toEven(value) {
3048
+ const rounded = Math.round(value);
3049
+ const even = rounded % 2 === 0 ? rounded : rounded - 1;
3050
+ return Math.max(2, even);
3051
+ }
3052
+ function assertWithin(rect, what, cellIndex) {
3053
+ if (rect.x < 0 || rect.y < 0 || rect.width <= 0 || rect.height <= 0 || rect.x + rect.width > 1.000001 || rect.y + rect.height > 1.000001) throw new Error(`camera-grid: cell ${cellIndex} falls ${what} (x=${rect.x} y=${rect.y} w=${rect.width} h=${rect.height}) — ffmpeg would render this as a silently clipped picture`);
3054
+ }
3055
+ /**
3056
+ * `crop` resolved against the source's OWN size at runtime (`iw`/`ih`), never
3057
+ * against a resolution guessed here. That is what makes the stored rectangle
3058
+ * survive a source that changes resolution.
3059
+ */
3060
+ function cropExpression(source) {
3061
+ return `crop=iw*${source.width}:ih*${source.height}:iw*${source.x}:ih*${source.y}`;
3062
+ }
3063
+ function buildGridFilterGraph(layout) {
3064
+ if (layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell to emit anything");
3065
+ const steps = [`color=c=black:s=${layout.width}x${layout.height}:d=1[${CANVAS_LABEL}]`];
3066
+ const cellLabels = [];
3067
+ layout.cells.forEach((cell, index) => {
3068
+ assertWithin(cell.source, "outside its source", index);
3069
+ assertWithin(cell.cell, "outside the canvas", index);
3070
+ const targetWidth = toEven(cell.cell.width * layout.width);
3071
+ const targetHeight = toEven(cell.cell.height * layout.height);
3072
+ const label = `c${index}`;
3073
+ const filters = [...isFullFrame(cell.source) ? [] : [cropExpression(cell.source)], `scale=${targetWidth}:${targetHeight}`];
3074
+ steps.push(`[${cell.inputIndex}:v]${filters.join(",")}[${label}]`);
3075
+ cellLabels.push(label);
3076
+ });
3077
+ let base = CANVAS_LABEL;
3078
+ layout.cells.forEach((cell, index) => {
3079
+ const x = Math.round(cell.cell.x * layout.width);
3080
+ const y = Math.round(cell.cell.y * layout.height);
3081
+ const out = index === layout.cells.length - 1 ? OUT_LABEL : `s${index}`;
3082
+ steps.push(`[${base}][${cellLabels[index]}]overlay=${x}:${y}[${out}]`);
3083
+ base = out;
3084
+ });
3085
+ return {
3086
+ graph: steps.join(";"),
3087
+ videoOutLabel: OUT_LABEL
3088
+ };
3089
+ }
3090
+ //#endregion
3091
+ //#region src/builtins/camera-grid/grid-stream-invocation.ts
3092
+ /**
3093
+ * The encoder preset. `veryfast` because a composite is the one encode in the
3094
+ * cluster whose input cost already scales with the number of cameras in it —
3095
+ * the decode side of a 2x2 is four decodes — so the encode side is where a
3096
+ * cheap default matters most.
3097
+ */
3098
+ var PRESET = "veryfast";
3099
+ /**
3100
+ * `-tune zerolatency`, and this one is worth several SECONDS.
3101
+ *
3102
+ * x264's defaults hold frames before emitting any: a rate-control lookahead of
3103
+ * ~40 frames plus B-frames. At a composite's 10 fps that lookahead alone is
3104
+ * about four seconds of picture sitting inside the encoder — the operator saw
3105
+ * it as "ritardi di secondi" and it was not the network, the relay or the
3106
+ * broker. `zerolatency` sets `rc-lookahead=0`, `sync-lookahead=0` and
3107
+ * `bframes=0`, which is exactly the trade a live composite wants: a few percent
3108
+ * more bitrate for the same picture, in exchange for the encoder emitting each
3109
+ * frame as it arrives.
3110
+ *
3111
+ * It is not a preset. `veryfast` says how hard the encoder searches; this says
3112
+ * how long it is allowed to WAIT, and the two are independent.
3113
+ */
3114
+ var TUNE = "zerolatency";
3115
+ /** yuv420p: the only pixel format every consumer of a CamStack profile reads. */
3116
+ var PIXEL_FORMAT = "yuv420p";
3117
+ /**
3118
+ * Key-frame cadence, in seconds.
3119
+ *
3120
+ * Two things ride on this and they pull the same way. A recorder cuts its
3121
+ * segments on a key frame, so a long GOP produces long segments and a scrub
3122
+ * that lands far from where the operator clicked. And every consumer — the
3123
+ * broker's reader included — starts at a key frame, so the GOP is the WORST
3124
+ * CASE wait before a grid appears at all: at two seconds a viewer could stare
3125
+ * at nothing for two seconds after pressing play.
3126
+ *
3127
+ * One second halves that wait. It costs bitrate (an IDR is expensive and there
3128
+ * are now twice as many), which is why it is not lower: below a second the
3129
+ * bitrate paid buys a wait nobody can feel.
3130
+ */
3131
+ var GOP_SECONDS = 1;
3132
+ /**
3133
+ * `-analyzeduration` / `-probesize`, as small as ffmpeg allows.
3134
+ *
3135
+ * ## The probes are SEQUENTIAL, and this is what the tile skew was
3136
+ *
3137
+ * The sentence that used to be here said the probes are "PARALLEL inputs of one
3138
+ * child". They are not. ffmpeg opens its inputs one after another and each open
3139
+ * BLOCKS in `avformat_find_stream_info` until its budget is spent — so input 0
3140
+ * is already connected and receiving while ffmpeg is still opening input 1, its
3141
+ * frames pile up, and `overlay` (which pairs inputs BY PTS) marries input 0's
3142
+ * oldest frame to input 1's newest. The lag is fixed for the life of the child:
3143
+ * both tiles then advance one frame per output frame, so nothing ever closes it.
3144
+ * The FIRST cell is the most behind; the last is live.
3145
+ *
3146
+ * Reproduced in one command on 2026-09-18 — two sources hstacked, one frame,
3147
+ * reading the cameras' own burnt-in clocks out of the composed picture:
3148
+ *
3149
+ * ```
3150
+ * 13:11:21 | 13:11:23 two seconds, and the earlier input is the late one
3151
+ * ```
3152
+ *
3153
+ * ## Why the old numbers cost seconds
3154
+ *
3155
+ * `-probesize` is a BYTE budget, and 1 MB of a 640x360 sub-stream is tens of
3156
+ * seconds of video — so `-analyzeduration 1s` never got the chance to stop it
3157
+ * and each open ran until the byte budget filled. Zero and 32 make the probe
3158
+ * return from the SDP, which already declares the codec, without reading media.
3159
+ *
3160
+ * Measured on this hub, the two sources of the live grid, three runs each,
3161
+ * production input args otherwise identical — time to the first composed frame,
3162
+ * and the skew read off the two burnt-in clocks in that frame:
3163
+ *
3164
+ * ```
3165
+ * 1 s / 1 MB 8986, 9728, 9712 ms skew 5 s, 5 s, 4 s
3166
+ * 0 / 32 2281, 2270, 2304 ms skew 1 s, 1 s, 0 s
3167
+ * ```
3168
+ *
3169
+ * The same numbers are the ~9 s cold start the operator had accepted: it was a
3170
+ * SUM of the opens, never the wait for the slowest source.
3171
+ *
3172
+ * Checked before shipping, because a probe that reads nothing has to survive
3173
+ * what it is not told: h264, h265 and a derived (`mid-to-low`) source all open
3174
+ * with `rc=0`, a 20 s run produces 200 frames of 200 either way (so the pacing
3175
+ * this file already lost once is untouched), and the skew is still 0 at 25 s
3176
+ * into a run — it is set at start-up and does not drift.
3177
+ *
3178
+ * What remains is the spread in when each source's first key frame arrives, and
3179
+ * a `/muted` dial waits for the next IDR. That spread is small here; if it ever
3180
+ * is not, the answer is to start the inputs from a join point we hold, NOT to
3181
+ * re-time the inputs (see `useWallclockTimestamps` below).
3182
+ */
3183
+ var ANALYZE_DURATION_US = 0;
3184
+ var PROBE_SIZE_BYTES = 32;
3185
+ function inputPlanFor(source) {
3186
+ return {
3187
+ url: source.url,
3188
+ rtspTransport: "tcp",
3189
+ fflags: ["+discardcorrupt"],
3190
+ lowDelay: true,
3191
+ analyzeDurationUs: ANALYZE_DURATION_US,
3192
+ probeSizeBytes: PROBE_SIZE_BYTES
3193
+ };
3194
+ }
3195
+ /**
3196
+ * @throws when the layout addresses an input the sources do not contain, or
3197
+ * when there is nothing to compose. Both are plan errors: ffmpeg would refuse
3198
+ * them at start-up with a message about a filter pad, which names the graph
3199
+ * rather than the configuration that produced it.
3200
+ */
3201
+ function gridStreamInvocation(input) {
3202
+ if (input.sources.length === 0 || input.layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell with a source before it can emit anything");
3203
+ for (const cell of input.layout.cells) if (cell.inputIndex < 0 || cell.inputIndex >= input.sources.length) throw new Error(`camera-grid: cell addresses input ordinal ${cell.inputIndex}, but only ${input.sources.length} input(s) were acquired`);
3204
+ const graph = buildGridFilterGraph(input.layout);
3205
+ const [first, ...rest] = input.sources;
3206
+ if (first === void 0) throw new Error("camera-grid: a composite needs at least one cell with a source");
3207
+ const video = {
3208
+ kind: "encode",
3209
+ encoder: input.encoder,
3210
+ preset: PRESET,
3211
+ tune: TUNE,
3212
+ pixelFormat: PIXEL_FORMAT,
3213
+ fps: input.fps,
3214
+ gopFrames: input.fps * GOP_SECONDS,
3215
+ bitrateKbps: input.bitrateKbps,
3216
+ scale: null
3217
+ };
3218
+ return {
3219
+ logLevel: "error",
3220
+ decodeHwAccel: input.decodeHwAccel,
3221
+ input: inputPlanFor(first),
3222
+ extraInputs: rest.map(inputPlanFor),
3223
+ filterGraph: graph,
3224
+ video,
3225
+ audio: { kind: "none" },
3226
+ threadCount: 0,
3227
+ outputArgs: [],
3228
+ sink: {
3229
+ kind: "stdout",
3230
+ container: "flv"
3231
+ }
3232
+ };
3233
+ }
3234
+ //#endregion
3235
+ //#region src/builtins/camera-grid/grid-child.ts
3236
+ /**
3237
+ * Spawning the composition, through the ONE primitive.
3238
+ *
3239
+ * `FfmpegProcess` (`@camstack/types`) owns spawn, the first-data deadline, the
3240
+ * hardware→software retry, exit classification and the bounded restart. This
3241
+ * file owns only the PLUMBING — which bytes go where — because that is the one
3242
+ * thing the primitive deliberately does not own.
3243
+ *
3244
+ * `maxRestarts: 0`. A composite is demand-driven: if the child dies, the
3245
+ * session tells every consumer why and tears down, and the consumer's next read
3246
+ * starts a cold one with freshly acquired sources. A restart inside the process
3247
+ * would reuse broker handles that may already have been released.
3248
+ */
3249
+ /** No first-data deadline retry loop: one attempt, and the session hears about it. */
3250
+ var FIRST_DATA_TIMEOUT_MS = 15e3;
3251
+ async function startGridChild(ctx, plan, sources, onEnded) {
3252
+ const invocation = (decodeHwAccel) => gridStreamInvocation({
3253
+ layout: plan.layout,
3254
+ sources: plan.sourceDeviceIds.map((deviceId, index) => ({
3255
+ deviceId,
3256
+ url: sources[index]?.url ?? ""
3257
+ })),
3258
+ decodeHwAccel,
3259
+ encoder: plan.encoder,
3260
+ fps: plan.fps,
3261
+ bitrateKbps: plan.bitrateKbps
3262
+ });
3263
+ let stdout = null;
3264
+ const process = new _camstack_types_node.FfmpegProcess({
3265
+ binaryPath: ctx.binaryPath,
3266
+ buildArgs: (decodeHwAccel) => require_dist.buildFfmpegArgs(invocation(decodeHwAccel)),
3267
+ decodeHwAccel: plan.decodeHwAccel,
3268
+ logger: ctx.logger,
3269
+ deviceId: ctx.deviceId,
3270
+ role: "camera-grid",
3271
+ tags: {
3272
+ cells: plan.layout.cells.length,
3273
+ inputs: plan.sourceDeviceIds.length
3274
+ },
3275
+ firstDataTimeoutMs: FIRST_DATA_TIMEOUT_MS,
3276
+ maxRestarts: 0,
3277
+ onChild: (child) => {
3278
+ if (child.stdout) stdout = child.stdout;
3279
+ child.stderr?.on("data", () => {});
3280
+ },
3281
+ onExit: (exit) => {
3282
+ onEnded(exit.classification);
3283
+ }
3284
+ });
3285
+ await process.start();
3286
+ if (stdout === null) {
3287
+ await process.stop();
3288
+ throw new Error("camera-grid: the composition child produced no stdout to read");
3289
+ }
3290
+ return {
3291
+ stdout,
3292
+ stop: async () => {
3293
+ await process.stop();
3294
+ }
3295
+ };
3296
+ }
3297
+ //#endregion
2729
3298
  exports.CameraGridAddon = CameraGridAddon;
2730
3299
  exports.GRID_CAM_STREAM_ID = GRID_CAM_STREAM_ID;
2731
3300
  exports.GRID_OUTPUT_PROFILE = GRID_OUTPUT_PROFILE;