@camstack/system 1.2.264 → 1.2.265

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