@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.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/camera-grid/grid-compositor-child.d.ts +3 -0
- package/dist/builtins/camera-grid/grid-compositor-invocation.d.ts +19 -0
- package/dist/builtins/camera-grid/grid-compositor.d.ts +2 -2
- package/dist/builtins/camera-grid/grid-tile-plan.d.ts +8 -9
- package/dist/builtins/camera-grid/index.js +781 -212
- package/dist/builtins/camera-grid/index.mjs +781 -212
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-BhYweLVr.js → dist-BbPssgI6.js} +12 -1
- package/dist/{dist-CO2O_ddo.mjs → dist-Ck0rjYOQ.mjs} +12 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{retired-settings-keys-BnnHWyfj.mjs → retired-settings-keys-BZpDuJVh.mjs} +1 -1
- package/dist/{retired-settings-keys-CKLI8VmE.js → retired-settings-keys-Dg_69cOY.js} +1 -1
- 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-
|
|
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-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
-
*
|
|
1821
|
-
*
|
|
1822
|
-
*
|
|
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
|
|
1825
|
-
return
|
|
1810
|
+
function evenDown(value) {
|
|
1811
|
+
return Math.max(0, Math.floor(value / 2) * 2);
|
|
1826
1812
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
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-
|
|
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
|
-
*
|
|
1882
|
+
* One source's decoded tiles, of which only the LATEST is ever wanted.
|
|
1865
1883
|
*
|
|
1866
|
-
*
|
|
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
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
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
|
-
*
|
|
1885
|
-
*
|
|
1886
|
-
*
|
|
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
|
-
*
|
|
1892
|
-
*
|
|
1893
|
-
*
|
|
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
|
|
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
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
* ##
|
|
1900
|
-
*
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
1905
|
-
*
|
|
1906
|
-
*
|
|
1907
|
-
*
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
1915
|
-
*
|
|
1916
|
-
*
|
|
1917
|
-
*
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
1923
|
-
*
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
1930
|
-
*
|
|
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
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
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
|
-
*
|
|
1961
|
-
*
|
|
1962
|
-
*
|
|
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
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
const
|
|
1970
|
-
|
|
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:
|
|
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:
|
|
1985
|
-
input:
|
|
1986
|
-
|
|
1987
|
-
|
|
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,194 @@ 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
|
-
*
|
|
2178
|
+
* The composition as processes: one decoder per tile, one encoder, one tick.
|
|
2002
2179
|
*
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2005
|
-
*
|
|
2006
|
-
*
|
|
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
|
-
*
|
|
2009
|
-
*
|
|
2010
|
-
*
|
|
2011
|
-
*
|
|
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
|
-
/**
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
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
|
-
|
|
2028
|
-
|
|
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
|
+
let draining = false;
|
|
2242
|
+
let tick = null;
|
|
2243
|
+
const compositor = new GridCompositor({
|
|
2244
|
+
plan: tilePlan,
|
|
2245
|
+
write: (frame) => {
|
|
2246
|
+
const sink = encoderIn;
|
|
2247
|
+
if (sink === null || draining || sink.destroyed) return false;
|
|
2248
|
+
const accepted = sink.write(frame);
|
|
2249
|
+
if (!accepted) draining = true;
|
|
2250
|
+
return accepted;
|
|
2251
|
+
}
|
|
2252
|
+
});
|
|
2253
|
+
const startTick = () => {
|
|
2254
|
+
if (tick !== null) return;
|
|
2255
|
+
tick = setInterval(() => {
|
|
2256
|
+
compositor.tick();
|
|
2257
|
+
}, Math.max(1, Math.round(1e3 / plan.fps)));
|
|
2258
|
+
tick.unref();
|
|
2259
|
+
};
|
|
2260
|
+
const encoder = new FfmpegProcess({
|
|
2029
2261
|
binaryPath: ctx.binaryPath,
|
|
2030
|
-
buildArgs: (
|
|
2031
|
-
|
|
2032
|
-
|
|
2262
|
+
buildArgs: () => buildFfmpegArgs(gridCanvasEncoderInvocation({
|
|
2263
|
+
canvas: tilePlan.canvas,
|
|
2264
|
+
fps: plan.fps,
|
|
2265
|
+
encoder: plan.encoder,
|
|
2266
|
+
bitrateKbps: plan.bitrateKbps
|
|
2267
|
+
})),
|
|
2268
|
+
decodeHwAccel: null,
|
|
2269
|
+
logger: ctx.logger.child("encode"),
|
|
2033
2270
|
deviceId: ctx.deviceId,
|
|
2034
|
-
role: "camera-grid",
|
|
2035
|
-
tags: {
|
|
2036
|
-
|
|
2037
|
-
inputs: plan.sourceDeviceIds.length
|
|
2038
|
-
},
|
|
2039
|
-
firstDataTimeoutMs: FIRST_DATA_TIMEOUT_MS,
|
|
2271
|
+
role: "camera-grid-encode",
|
|
2272
|
+
tags: { tiles: tilePlan.tiles.length },
|
|
2273
|
+
firstDataTimeoutMs: ENCODER_FIRST_DATA_MS,
|
|
2040
2274
|
maxRestarts: 0,
|
|
2275
|
+
stdio: [
|
|
2276
|
+
"pipe",
|
|
2277
|
+
"pipe",
|
|
2278
|
+
"pipe"
|
|
2279
|
+
],
|
|
2041
2280
|
onChild: (child) => {
|
|
2042
|
-
|
|
2281
|
+
encoderIn = child.stdin;
|
|
2282
|
+
if (child.stdout) encoderOut = child.stdout;
|
|
2283
|
+
child.stdin?.on("drain", () => {
|
|
2284
|
+
draining = false;
|
|
2285
|
+
});
|
|
2286
|
+
startTick();
|
|
2043
2287
|
child.stderr?.on("data", () => {});
|
|
2288
|
+
child.stdin?.on("error", () => {});
|
|
2044
2289
|
},
|
|
2045
|
-
onExit: (exit) => {
|
|
2046
|
-
|
|
2290
|
+
onExit: (exit) => end(`encoder:${exit.classification}`)
|
|
2291
|
+
});
|
|
2292
|
+
try {
|
|
2293
|
+
await encoder.start();
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
if (tick !== null) clearInterval(tick);
|
|
2296
|
+
compositor.close();
|
|
2297
|
+
throw error;
|
|
2298
|
+
}
|
|
2299
|
+
if (encoderIn === null || encoderOut === null) {
|
|
2300
|
+
await encoder.stop();
|
|
2301
|
+
throw new Error("camera-grid: the canvas encoder exposed no stdin/stdout to compose into");
|
|
2302
|
+
}
|
|
2303
|
+
const urlByDeviceId = /* @__PURE__ */ new Map();
|
|
2304
|
+
for (const [index, deviceId] of plan.sourceDeviceIds.entries()) {
|
|
2305
|
+
const url = sources[index]?.url;
|
|
2306
|
+
if (url !== void 0) urlByDeviceId.set(deviceId, url);
|
|
2307
|
+
}
|
|
2308
|
+
const decoders = [];
|
|
2309
|
+
const startDecoder = async (tile) => {
|
|
2310
|
+
const url = urlByDeviceId.get(tile.deviceId);
|
|
2311
|
+
if (url === void 0) throw new Error(`camera-grid: no acquired source for device ${String(tile.deviceId)}`);
|
|
2312
|
+
const decoder = new FfmpegProcess({
|
|
2313
|
+
binaryPath: ctx.binaryPath,
|
|
2314
|
+
buildArgs: () => buildFfmpegArgs(gridTileDecoderInvocation({
|
|
2315
|
+
tile,
|
|
2316
|
+
url,
|
|
2317
|
+
fps: plan.fps
|
|
2318
|
+
})),
|
|
2319
|
+
decodeHwAccel: null,
|
|
2320
|
+
logger: ctx.logger.child("tile"),
|
|
2321
|
+
deviceId: tile.deviceId,
|
|
2322
|
+
role: "camera-grid-tile",
|
|
2323
|
+
tags: {
|
|
2324
|
+
tile: tile.id,
|
|
2325
|
+
width: tile.size.width,
|
|
2326
|
+
height: tile.size.height
|
|
2327
|
+
},
|
|
2328
|
+
firstDataTimeoutMs: DECODER_FIRST_DATA_MS,
|
|
2329
|
+
maxRestarts: 0,
|
|
2330
|
+
onChild: (child) => {
|
|
2331
|
+
child.stdout?.on("data", (chunk) => {
|
|
2332
|
+
compositor.onTile(tile.id, chunk);
|
|
2333
|
+
});
|
|
2334
|
+
child.stderr?.on("data", () => {});
|
|
2335
|
+
},
|
|
2336
|
+
onExit: (exit) => end(`tile ${tile.id}:${exit.classification}`)
|
|
2337
|
+
});
|
|
2338
|
+
decoders.push(decoder);
|
|
2339
|
+
await decoder.start();
|
|
2340
|
+
};
|
|
2341
|
+
const stopAll = async () => {
|
|
2342
|
+
if (tick !== null) clearInterval(tick);
|
|
2343
|
+
compositor.close();
|
|
2344
|
+
await Promise.allSettled(decoders.map((d) => d.stop()));
|
|
2345
|
+
await encoder.stop();
|
|
2346
|
+
};
|
|
2347
|
+
try {
|
|
2348
|
+
for (const tile of tilePlan.tiles) await startDecoder(tile);
|
|
2349
|
+
} catch (error) {
|
|
2350
|
+
await stopAll();
|
|
2351
|
+
throw error;
|
|
2352
|
+
}
|
|
2353
|
+
ctx.logger.info("camera grid: composing on an output clock", {
|
|
2354
|
+
tags: { deviceId: ctx.deviceId },
|
|
2355
|
+
meta: {
|
|
2356
|
+
tiles: tilePlan.tiles.length,
|
|
2357
|
+
canvas: `${String(tilePlan.canvas.width)}x${String(tilePlan.canvas.height)}`,
|
|
2358
|
+
fps: plan.fps
|
|
2047
2359
|
}
|
|
2048
2360
|
});
|
|
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
2361
|
return {
|
|
2055
|
-
stdout,
|
|
2056
|
-
stop:
|
|
2057
|
-
await process.stop();
|
|
2058
|
-
}
|
|
2362
|
+
stdout: encoderOut,
|
|
2363
|
+
stop: stopAll
|
|
2059
2364
|
};
|
|
2060
2365
|
}
|
|
2061
2366
|
//#endregion
|
|
@@ -2498,7 +2803,7 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2498
2803
|
releaseSource: async (pipelineKey) => {
|
|
2499
2804
|
await this.ctx.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey });
|
|
2500
2805
|
},
|
|
2501
|
-
startChild: async (plan, sources) =>
|
|
2806
|
+
startChild: async (plan, sources) => startGridCompositorChild(childContext, plan, sources, (reason) => {
|
|
2502
2807
|
session.onChildEnded(reason);
|
|
2503
2808
|
})
|
|
2504
2809
|
},
|
|
@@ -2721,4 +3026,268 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2721
3026
|
}
|
|
2722
3027
|
};
|
|
2723
3028
|
//#endregion
|
|
3029
|
+
//#region src/builtins/camera-grid/grid-filter-graph.ts
|
|
3030
|
+
var CANVAS_LABEL = "canvas";
|
|
3031
|
+
var OUT_LABEL = "grid";
|
|
3032
|
+
/** Full-frame within a tolerance, i.e. nothing to crop. */
|
|
3033
|
+
var FULL_FRAME_EPSILON = 1e-6;
|
|
3034
|
+
function isFullFrame(rect) {
|
|
3035
|
+
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;
|
|
3036
|
+
}
|
|
3037
|
+
/**
|
|
3038
|
+
* yuv420p subsamples chroma 2x2, so an odd width or height is rejected by every
|
|
3039
|
+
* encoder we use. Rounding here lets the reason be stated; rounding inside the
|
|
3040
|
+
* child is a start-up failure with no context.
|
|
3041
|
+
*/
|
|
3042
|
+
function toEven(value) {
|
|
3043
|
+
const rounded = Math.round(value);
|
|
3044
|
+
const even = rounded % 2 === 0 ? rounded : rounded - 1;
|
|
3045
|
+
return Math.max(2, even);
|
|
3046
|
+
}
|
|
3047
|
+
function assertWithin(rect, what, cellIndex) {
|
|
3048
|
+
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`);
|
|
3049
|
+
}
|
|
3050
|
+
/**
|
|
3051
|
+
* `crop` resolved against the source's OWN size at runtime (`iw`/`ih`), never
|
|
3052
|
+
* against a resolution guessed here. That is what makes the stored rectangle
|
|
3053
|
+
* survive a source that changes resolution.
|
|
3054
|
+
*/
|
|
3055
|
+
function cropExpression(source) {
|
|
3056
|
+
return `crop=iw*${source.width}:ih*${source.height}:iw*${source.x}:ih*${source.y}`;
|
|
3057
|
+
}
|
|
3058
|
+
function buildGridFilterGraph(layout) {
|
|
3059
|
+
if (layout.cells.length === 0) throw new Error("camera-grid: a composite needs at least one cell to emit anything");
|
|
3060
|
+
const steps = [`color=c=black:s=${layout.width}x${layout.height}:d=1[${CANVAS_LABEL}]`];
|
|
3061
|
+
const cellLabels = [];
|
|
3062
|
+
layout.cells.forEach((cell, index) => {
|
|
3063
|
+
assertWithin(cell.source, "outside its source", index);
|
|
3064
|
+
assertWithin(cell.cell, "outside the canvas", index);
|
|
3065
|
+
const targetWidth = toEven(cell.cell.width * layout.width);
|
|
3066
|
+
const targetHeight = toEven(cell.cell.height * layout.height);
|
|
3067
|
+
const label = `c${index}`;
|
|
3068
|
+
const filters = [...isFullFrame(cell.source) ? [] : [cropExpression(cell.source)], `scale=${targetWidth}:${targetHeight}`];
|
|
3069
|
+
steps.push(`[${cell.inputIndex}:v]${filters.join(",")}[${label}]`);
|
|
3070
|
+
cellLabels.push(label);
|
|
3071
|
+
});
|
|
3072
|
+
let base = CANVAS_LABEL;
|
|
3073
|
+
layout.cells.forEach((cell, index) => {
|
|
3074
|
+
const x = Math.round(cell.cell.x * layout.width);
|
|
3075
|
+
const y = Math.round(cell.cell.y * layout.height);
|
|
3076
|
+
const out = index === layout.cells.length - 1 ? OUT_LABEL : `s${index}`;
|
|
3077
|
+
steps.push(`[${base}][${cellLabels[index]}]overlay=${x}:${y}[${out}]`);
|
|
3078
|
+
base = out;
|
|
3079
|
+
});
|
|
3080
|
+
return {
|
|
3081
|
+
graph: steps.join(";"),
|
|
3082
|
+
videoOutLabel: OUT_LABEL
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
//#endregion
|
|
3086
|
+
//#region src/builtins/camera-grid/grid-stream-invocation.ts
|
|
3087
|
+
/**
|
|
3088
|
+
* The encoder preset. `veryfast` because a composite is the one encode in the
|
|
3089
|
+
* cluster whose input cost already scales with the number of cameras in it —
|
|
3090
|
+
* the decode side of a 2x2 is four decodes — so the encode side is where a
|
|
3091
|
+
* cheap default matters most.
|
|
3092
|
+
*/
|
|
3093
|
+
var PRESET = "veryfast";
|
|
3094
|
+
/**
|
|
3095
|
+
* `-tune zerolatency`, and this one is worth several SECONDS.
|
|
3096
|
+
*
|
|
3097
|
+
* x264's defaults hold frames before emitting any: a rate-control lookahead of
|
|
3098
|
+
* ~40 frames plus B-frames. At a composite's 10 fps that lookahead alone is
|
|
3099
|
+
* about four seconds of picture sitting inside the encoder — the operator saw
|
|
3100
|
+
* it as "ritardi di secondi" and it was not the network, the relay or the
|
|
3101
|
+
* broker. `zerolatency` sets `rc-lookahead=0`, `sync-lookahead=0` and
|
|
3102
|
+
* `bframes=0`, which is exactly the trade a live composite wants: a few percent
|
|
3103
|
+
* more bitrate for the same picture, in exchange for the encoder emitting each
|
|
3104
|
+
* frame as it arrives.
|
|
3105
|
+
*
|
|
3106
|
+
* It is not a preset. `veryfast` says how hard the encoder searches; this says
|
|
3107
|
+
* how long it is allowed to WAIT, and the two are independent.
|
|
3108
|
+
*/
|
|
3109
|
+
var TUNE = "zerolatency";
|
|
3110
|
+
/** yuv420p: the only pixel format every consumer of a CamStack profile reads. */
|
|
3111
|
+
var PIXEL_FORMAT = "yuv420p";
|
|
3112
|
+
/**
|
|
3113
|
+
* Key-frame cadence, in seconds.
|
|
3114
|
+
*
|
|
3115
|
+
* Two things ride on this and they pull the same way. A recorder cuts its
|
|
3116
|
+
* segments on a key frame, so a long GOP produces long segments and a scrub
|
|
3117
|
+
* that lands far from where the operator clicked. And every consumer — the
|
|
3118
|
+
* broker's reader included — starts at a key frame, so the GOP is the WORST
|
|
3119
|
+
* CASE wait before a grid appears at all: at two seconds a viewer could stare
|
|
3120
|
+
* at nothing for two seconds after pressing play.
|
|
3121
|
+
*
|
|
3122
|
+
* One second halves that wait. It costs bitrate (an IDR is expensive and there
|
|
3123
|
+
* are now twice as many), which is why it is not lower: below a second the
|
|
3124
|
+
* bitrate paid buys a wait nobody can feel.
|
|
3125
|
+
*/
|
|
3126
|
+
var GOP_SECONDS = 1;
|
|
3127
|
+
/**
|
|
3128
|
+
* `-analyzeduration` / `-probesize`, as small as ffmpeg allows.
|
|
3129
|
+
*
|
|
3130
|
+
* ## The probes are SEQUENTIAL, and this is what the tile skew was
|
|
3131
|
+
*
|
|
3132
|
+
* The sentence that used to be here said the probes are "PARALLEL inputs of one
|
|
3133
|
+
* child". They are not. ffmpeg opens its inputs one after another and each open
|
|
3134
|
+
* BLOCKS in `avformat_find_stream_info` until its budget is spent — so input 0
|
|
3135
|
+
* is already connected and receiving while ffmpeg is still opening input 1, its
|
|
3136
|
+
* frames pile up, and `overlay` (which pairs inputs BY PTS) marries input 0's
|
|
3137
|
+
* oldest frame to input 1's newest. The lag is fixed for the life of the child:
|
|
3138
|
+
* both tiles then advance one frame per output frame, so nothing ever closes it.
|
|
3139
|
+
* The FIRST cell is the most behind; the last is live.
|
|
3140
|
+
*
|
|
3141
|
+
* Reproduced in one command on 2026-09-18 — two sources hstacked, one frame,
|
|
3142
|
+
* reading the cameras' own burnt-in clocks out of the composed picture:
|
|
3143
|
+
*
|
|
3144
|
+
* ```
|
|
3145
|
+
* 13:11:21 | 13:11:23 two seconds, and the earlier input is the late one
|
|
3146
|
+
* ```
|
|
3147
|
+
*
|
|
3148
|
+
* ## Why the old numbers cost seconds
|
|
3149
|
+
*
|
|
3150
|
+
* `-probesize` is a BYTE budget, and 1 MB of a 640x360 sub-stream is tens of
|
|
3151
|
+
* seconds of video — so `-analyzeduration 1s` never got the chance to stop it
|
|
3152
|
+
* and each open ran until the byte budget filled. Zero and 32 make the probe
|
|
3153
|
+
* return from the SDP, which already declares the codec, without reading media.
|
|
3154
|
+
*
|
|
3155
|
+
* Measured on this hub, the two sources of the live grid, three runs each,
|
|
3156
|
+
* production input args otherwise identical — time to the first composed frame,
|
|
3157
|
+
* and the skew read off the two burnt-in clocks in that frame:
|
|
3158
|
+
*
|
|
3159
|
+
* ```
|
|
3160
|
+
* 1 s / 1 MB 8986, 9728, 9712 ms skew 5 s, 5 s, 4 s
|
|
3161
|
+
* 0 / 32 2281, 2270, 2304 ms skew 1 s, 1 s, 0 s
|
|
3162
|
+
* ```
|
|
3163
|
+
*
|
|
3164
|
+
* The same numbers are the ~9 s cold start the operator had accepted: it was a
|
|
3165
|
+
* SUM of the opens, never the wait for the slowest source.
|
|
3166
|
+
*
|
|
3167
|
+
* Checked before shipping, because a probe that reads nothing has to survive
|
|
3168
|
+
* what it is not told: h264, h265 and a derived (`mid-to-low`) source all open
|
|
3169
|
+
* with `rc=0`, a 20 s run produces 200 frames of 200 either way (so the pacing
|
|
3170
|
+
* this file already lost once is untouched), and the skew is still 0 at 25 s
|
|
3171
|
+
* into a run — it is set at start-up and does not drift.
|
|
3172
|
+
*
|
|
3173
|
+
* What remains is the spread in when each source's first key frame arrives, and
|
|
3174
|
+
* a `/muted` dial waits for the next IDR. That spread is small here; if it ever
|
|
3175
|
+
* is not, the answer is to start the inputs from a join point we hold, NOT to
|
|
3176
|
+
* re-time the inputs (see `useWallclockTimestamps` below).
|
|
3177
|
+
*/
|
|
3178
|
+
var ANALYZE_DURATION_US = 0;
|
|
3179
|
+
var PROBE_SIZE_BYTES = 32;
|
|
3180
|
+
function inputPlanFor(source) {
|
|
3181
|
+
return {
|
|
3182
|
+
url: source.url,
|
|
3183
|
+
rtspTransport: "tcp",
|
|
3184
|
+
fflags: ["+discardcorrupt"],
|
|
3185
|
+
lowDelay: true,
|
|
3186
|
+
analyzeDurationUs: ANALYZE_DURATION_US,
|
|
3187
|
+
probeSizeBytes: PROBE_SIZE_BYTES
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
3190
|
+
/**
|
|
3191
|
+
* @throws when the layout addresses an input the sources do not contain, or
|
|
3192
|
+
* when there is nothing to compose. Both are plan errors: ffmpeg would refuse
|
|
3193
|
+
* them at start-up with a message about a filter pad, which names the graph
|
|
3194
|
+
* rather than the configuration that produced it.
|
|
3195
|
+
*/
|
|
3196
|
+
function gridStreamInvocation(input) {
|
|
3197
|
+
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");
|
|
3198
|
+
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`);
|
|
3199
|
+
const graph = buildGridFilterGraph(input.layout);
|
|
3200
|
+
const [first, ...rest] = input.sources;
|
|
3201
|
+
if (first === void 0) throw new Error("camera-grid: a composite needs at least one cell with a source");
|
|
3202
|
+
const video = {
|
|
3203
|
+
kind: "encode",
|
|
3204
|
+
encoder: input.encoder,
|
|
3205
|
+
preset: PRESET,
|
|
3206
|
+
tune: TUNE,
|
|
3207
|
+
pixelFormat: PIXEL_FORMAT,
|
|
3208
|
+
fps: input.fps,
|
|
3209
|
+
gopFrames: input.fps * GOP_SECONDS,
|
|
3210
|
+
bitrateKbps: input.bitrateKbps,
|
|
3211
|
+
scale: null
|
|
3212
|
+
};
|
|
3213
|
+
return {
|
|
3214
|
+
logLevel: "error",
|
|
3215
|
+
decodeHwAccel: input.decodeHwAccel,
|
|
3216
|
+
input: inputPlanFor(first),
|
|
3217
|
+
extraInputs: rest.map(inputPlanFor),
|
|
3218
|
+
filterGraph: graph,
|
|
3219
|
+
video,
|
|
3220
|
+
audio: { kind: "none" },
|
|
3221
|
+
threadCount: 0,
|
|
3222
|
+
outputArgs: [],
|
|
3223
|
+
sink: {
|
|
3224
|
+
kind: "stdout",
|
|
3225
|
+
container: "flv"
|
|
3226
|
+
}
|
|
3227
|
+
};
|
|
3228
|
+
}
|
|
3229
|
+
//#endregion
|
|
3230
|
+
//#region src/builtins/camera-grid/grid-child.ts
|
|
3231
|
+
/**
|
|
3232
|
+
* Spawning the composition, through the ONE primitive.
|
|
3233
|
+
*
|
|
3234
|
+
* `FfmpegProcess` (`@camstack/types`) owns spawn, the first-data deadline, the
|
|
3235
|
+
* hardware→software retry, exit classification and the bounded restart. This
|
|
3236
|
+
* file owns only the PLUMBING — which bytes go where — because that is the one
|
|
3237
|
+
* thing the primitive deliberately does not own.
|
|
3238
|
+
*
|
|
3239
|
+
* `maxRestarts: 0`. A composite is demand-driven: if the child dies, the
|
|
3240
|
+
* session tells every consumer why and tears down, and the consumer's next read
|
|
3241
|
+
* starts a cold one with freshly acquired sources. A restart inside the process
|
|
3242
|
+
* would reuse broker handles that may already have been released.
|
|
3243
|
+
*/
|
|
3244
|
+
/** No first-data deadline retry loop: one attempt, and the session hears about it. */
|
|
3245
|
+
var FIRST_DATA_TIMEOUT_MS = 15e3;
|
|
3246
|
+
async function startGridChild(ctx, plan, sources, onEnded) {
|
|
3247
|
+
const invocation = (decodeHwAccel) => gridStreamInvocation({
|
|
3248
|
+
layout: plan.layout,
|
|
3249
|
+
sources: plan.sourceDeviceIds.map((deviceId, index) => ({
|
|
3250
|
+
deviceId,
|
|
3251
|
+
url: sources[index]?.url ?? ""
|
|
3252
|
+
})),
|
|
3253
|
+
decodeHwAccel,
|
|
3254
|
+
encoder: plan.encoder,
|
|
3255
|
+
fps: plan.fps,
|
|
3256
|
+
bitrateKbps: plan.bitrateKbps
|
|
3257
|
+
});
|
|
3258
|
+
let stdout = null;
|
|
3259
|
+
const process = new FfmpegProcess({
|
|
3260
|
+
binaryPath: ctx.binaryPath,
|
|
3261
|
+
buildArgs: (decodeHwAccel) => buildFfmpegArgs(invocation(decodeHwAccel)),
|
|
3262
|
+
decodeHwAccel: plan.decodeHwAccel,
|
|
3263
|
+
logger: ctx.logger,
|
|
3264
|
+
deviceId: ctx.deviceId,
|
|
3265
|
+
role: "camera-grid",
|
|
3266
|
+
tags: {
|
|
3267
|
+
cells: plan.layout.cells.length,
|
|
3268
|
+
inputs: plan.sourceDeviceIds.length
|
|
3269
|
+
},
|
|
3270
|
+
firstDataTimeoutMs: FIRST_DATA_TIMEOUT_MS,
|
|
3271
|
+
maxRestarts: 0,
|
|
3272
|
+
onChild: (child) => {
|
|
3273
|
+
if (child.stdout) stdout = child.stdout;
|
|
3274
|
+
child.stderr?.on("data", () => {});
|
|
3275
|
+
},
|
|
3276
|
+
onExit: (exit) => {
|
|
3277
|
+
onEnded(exit.classification);
|
|
3278
|
+
}
|
|
3279
|
+
});
|
|
3280
|
+
await process.start();
|
|
3281
|
+
if (stdout === null) {
|
|
3282
|
+
await process.stop();
|
|
3283
|
+
throw new Error("camera-grid: the composition child produced no stdout to read");
|
|
3284
|
+
}
|
|
3285
|
+
return {
|
|
3286
|
+
stdout,
|
|
3287
|
+
stop: async () => {
|
|
3288
|
+
await process.stop();
|
|
3289
|
+
}
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
//#endregion
|
|
2724
3293
|
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 };
|