@camstack/types 1.2.224 → 1.2.226
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/addon.js +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/{canonical-hash-CvL03d3i.mjs → canonical-hash-CPK2Dy60.mjs} +31 -8
- package/dist/{canonical-hash-DYsYm8is.js → canonical-hash-CSE4ioRi.js} +31 -8
- package/dist/capabilities/index.d.ts +3 -3
- package/dist/capabilities/pipeline-analytics.cap.d.ts +21 -0
- package/dist/capabilities/pipeline-runner.cap.d.ts +10 -0
- package/dist/capabilities/schemas/streaming-shared.d.ts +16 -0
- package/dist/capabilities/webrtc-session.cap.d.ts +189 -0
- package/dist/deps/ffmpeg-binary-source.d.ts +74 -0
- package/dist/deps/ffmpeg-downloader.d.ts +11 -2
- package/dist/ffmpeg/binary-capabilities.d.ts +31 -0
- package/dist/ffmpeg/invocation.d.ts +34 -24
- package/dist/generated/addon-api.d.ts +7 -0
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/method-device-selectors.d.ts +1 -1
- package/dist/index.js +149 -3
- package/dist/index.mjs +146 -4
- package/dist/interfaces/pipeline-runner-capability.d.ts +9 -0
- package/dist/interfaces/stream-broker.d.ts +15 -2
- package/dist/node.d.ts +1 -0
- package/dist/node.js +234 -13
- package/dist/node.mjs +234 -15
- package/dist/{sleep-GU_us3DG.mjs → sleep-CiR4z7r9.mjs} +14 -2
- package/dist/{sleep-VlZxDyXq.js → sleep-D7DPSeiv.js} +19 -1
- package/package.json +1 -1
package/dist/node.js
CHANGED
|
@@ -21,7 +21,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
21
21
|
enumerable: true
|
|
22
22
|
}) : target, mod));
|
|
23
23
|
//#endregion
|
|
24
|
-
const require_canonical_hash = require("./canonical-hash-
|
|
24
|
+
const require_canonical_hash = require("./canonical-hash-CSE4ioRi.js");
|
|
25
25
|
const require_err_msg = require("./err-msg-COpsHMw2.js");
|
|
26
26
|
let node_crypto = require("node:crypto");
|
|
27
27
|
let node_fs = require("node:fs");
|
|
@@ -185,16 +185,208 @@ async function ensureBinary(opts) {
|
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
187
|
//#endregion
|
|
188
|
+
//#region src/ffmpeg/binary-capabilities.ts
|
|
189
|
+
/**
|
|
190
|
+
* What an ffmpeg binary can actually DO, asked of the binary itself.
|
|
191
|
+
*
|
|
192
|
+
* ## The failure this exists to end
|
|
193
|
+
*
|
|
194
|
+
* `ensureFfmpeg` prefers a downloaded STATIC build over the system one, and a
|
|
195
|
+
* static build is compiled for portability: no vendor libraries, no VAAPI, no
|
|
196
|
+
* QSV. On a machine with a working Intel iGPU that is precisely backwards, and
|
|
197
|
+
* the symptom does not say so. Measured on the hub, 2026-09-18:
|
|
198
|
+
*
|
|
199
|
+
* ```
|
|
200
|
+
* /data/deps/ffmpeg 7.0.2 johnvansickle static hwaccels: vdpau
|
|
201
|
+
* /usr/bin/ffmpeg 6.1.1 Ubuntu, in the image hwaccels: vdpau cuda vaapi qsv drm opencl vulkan
|
|
202
|
+
* ```
|
|
203
|
+
*
|
|
204
|
+
* Every consumer that went through `ensureFfmpeg` — the broker's egress and
|
|
205
|
+
* derived transcodes, the WebRTC transcode leg, the camera grid — asked for
|
|
206
|
+
* `-hwaccel vaapi`, got `Device creation failed: -12`, and fell back to
|
|
207
|
+
* software. `-12` is what fftools prints when `av_hwdevice_ctx_alloc` returns
|
|
208
|
+
* NULL because the type is not in the build: the NAME parses, so the request
|
|
209
|
+
* looks valid and the device simply cannot exist. It reads as a resource
|
|
210
|
+
* problem and is a build property.
|
|
211
|
+
*
|
|
212
|
+
* ## Why asking the kernel was not enough
|
|
213
|
+
*
|
|
214
|
+
* The existing hardware resolver answers from `/dev/dri` presence. That is a
|
|
215
|
+
* correct description of the NODE, and it is what `node-av` needs — the
|
|
216
|
+
* detection workers were on VAAPI the whole time. It says nothing about the
|
|
217
|
+
* separate binary a child will be spawned with, and it was being read as
|
|
218
|
+
* though it did.
|
|
219
|
+
*
|
|
220
|
+
* So: two questions, two answers. What does this node HAVE (the resolver), and
|
|
221
|
+
* what can this BINARY use (here). A backend needs both.
|
|
222
|
+
*/
|
|
223
|
+
/** How long a probe may take before the binary counts as unusable. */
|
|
224
|
+
var PROBE_TIMEOUT_MS = 5e3;
|
|
225
|
+
function run(path, args) {
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
(0, node_child_process.execFile)(path, [...args], { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
|
|
228
|
+
resolve(error ? "" : stdout);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* `-hwaccels` prints a header line and then one name per line. Anything with a
|
|
234
|
+
* space in it is prose, not a name.
|
|
235
|
+
*/
|
|
236
|
+
function parseNames(output) {
|
|
237
|
+
const names = /* @__PURE__ */ new Set();
|
|
238
|
+
for (const raw of output.split("\n")) {
|
|
239
|
+
const line = raw.trim();
|
|
240
|
+
if (line.length === 0 || line.includes(" ")) continue;
|
|
241
|
+
names.add(line);
|
|
242
|
+
}
|
|
243
|
+
return names;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* `-encoders` prints a LEGEND and then a table, separated by a dashed line:
|
|
247
|
+
*
|
|
248
|
+
* ```
|
|
249
|
+
* Encoders:
|
|
250
|
+
* V..... = Video
|
|
251
|
+
* .F.... = Frame-level multithreading
|
|
252
|
+
* ------
|
|
253
|
+
* V....D h264_vaapi H.264/AVC (VAAPI) (codec h264)
|
|
254
|
+
* ```
|
|
255
|
+
*
|
|
256
|
+
* The legend rows have the SAME leading flag shape as the table rows, so a
|
|
257
|
+
* pattern alone matches ` V..... = Video` and yields `=` as an encoder name.
|
|
258
|
+
* Verified against the hub's own output on 2026-09-18, which is how that was
|
|
259
|
+
* caught. So the separator is the parser's state: nothing counts before it.
|
|
260
|
+
*/
|
|
261
|
+
function parseEncoders(output) {
|
|
262
|
+
const names = /* @__PURE__ */ new Set();
|
|
263
|
+
let inTable = false;
|
|
264
|
+
for (const raw of output.split("\n")) {
|
|
265
|
+
if (!inTable) {
|
|
266
|
+
if (raw.trim().startsWith("---")) inTable = true;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const match = /^\s[VAS][.F][.S][.X][.B][.D]\s+(\S+)/.exec(raw);
|
|
270
|
+
if (match?.[1] !== void 0) names.add(match[1]);
|
|
271
|
+
}
|
|
272
|
+
return names;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Turn three raw outputs into capabilities. Separated from the spawning so the
|
|
276
|
+
* parsers can be held against what ffmpeg REALLY prints — which is how the
|
|
277
|
+
* legend rows were caught being read as encoder names.
|
|
278
|
+
*/
|
|
279
|
+
function parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut) {
|
|
280
|
+
return {
|
|
281
|
+
path,
|
|
282
|
+
version: versionOut.split("\n")[0]?.trim() ?? "",
|
|
283
|
+
hwaccels: parseNames(hwaccelOut),
|
|
284
|
+
encoders: parseEncoders(encoderOut)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** Ask one binary what it can do. Never throws — an unusable binary answers empty. */
|
|
288
|
+
async function probeFfmpegBinary(path) {
|
|
289
|
+
const [versionOut, hwaccelOut, encoderOut] = await Promise.all([
|
|
290
|
+
run(path, ["-hide_banner", "-version"]),
|
|
291
|
+
run(path, ["-hide_banner", "-hwaccels"]),
|
|
292
|
+
run(path, ["-hide_banner", "-encoders"])
|
|
293
|
+
]);
|
|
294
|
+
return parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut);
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/deps/ffmpeg-binary-source.ts
|
|
298
|
+
/**
|
|
299
|
+
* WHICH ffmpeg a node runs, and why it is never the host's.
|
|
300
|
+
*
|
|
301
|
+
* CamStack spawns ffmpeg for the broker's egress and derived transcodes, the
|
|
302
|
+
* WebRTC transcode leg, the recorder, snapshots and the camera grid. What that
|
|
303
|
+
* binary is compiled with decides whether any of them can use the machine's
|
|
304
|
+
* hardware, so it is not a detail to leave to whatever the host happens to have
|
|
305
|
+
* on its PATH:
|
|
306
|
+
*
|
|
307
|
+
* - a host binary is an unknown version with unknown vendor libraries, and it
|
|
308
|
+
* changes under us on any host update;
|
|
309
|
+
* - a self-hosted NVR cannot assume the host has ffmpeg at all.
|
|
310
|
+
*
|
|
311
|
+
* So the binary is always OURS. Two ways it can be ours, and which one applies
|
|
312
|
+
* is a property of where the node runs — not of an environment variable:
|
|
313
|
+
*
|
|
314
|
+
* | Node | Binary |
|
|
315
|
+
* | --- | --- |
|
|
316
|
+
* | our container image | {@link BUNDLED_FFMPEG_PATH}, installed by the image |
|
|
317
|
+
* | anything else (a native macOS agent) | the static build we download into `<dataDir>/deps` |
|
|
318
|
+
*
|
|
319
|
+
* `BUNDLED_FFMPEG_PATH` is a CamStack-owned path that only our image creates
|
|
320
|
+
* (a link to the ffmpeg the image installs). That is what makes "is this ours?"
|
|
321
|
+
* answerable: `/usr/bin/ffmpeg` is ours INSIDE the image and the host's outside
|
|
322
|
+
* it, and nothing in the process can tell those apart. A path only we ever
|
|
323
|
+
* write can.
|
|
324
|
+
*
|
|
325
|
+
* The image build is the reason to prefer it over the download: the image
|
|
326
|
+
* installs Ubuntu's ffmpeg next to the Intel media driver, so it carries VAAPI
|
|
327
|
+
* and QSV. The portable static builds carry neither — measured on the hub,
|
|
328
|
+
* 2026-09-18:
|
|
329
|
+
*
|
|
330
|
+
* ```
|
|
331
|
+
* /data/deps/ffmpeg 7.0.2 johnvansickle static hwaccels: vdpau
|
|
332
|
+
* /usr/bin/ffmpeg 6.1.1 Ubuntu, in the image hwaccels: vdpau cuda vaapi qsv drm opencl vulkan
|
|
333
|
+
* ```
|
|
334
|
+
*
|
|
335
|
+
* A child spawned with the first one asks for `-hwaccel vaapi` and gets
|
|
336
|
+
* `Device creation failed: -12` — the name parses, the device cannot exist —
|
|
337
|
+
* then transcodes in software with nothing naming the cause.
|
|
338
|
+
*
|
|
339
|
+
* {@link FFMPEG_PATH_ENV} is an operator's VOLUNTARY override, and only that:
|
|
340
|
+
* unset is the normal, correct state on every node, and nothing in the product
|
|
341
|
+
* sets it.
|
|
342
|
+
*/
|
|
343
|
+
/** Where our container image puts the ffmpeg it installs. Only the image writes this. */
|
|
344
|
+
var BUNDLED_FFMPEG_PATH = "/opt/camstack/bin/ffmpeg";
|
|
345
|
+
/** Operator override. Unset on every node unless a human deliberately set it. */
|
|
346
|
+
var FFMPEG_PATH_ENV = "CAMSTACK_FFMPEG_PATH";
|
|
347
|
+
/**
|
|
348
|
+
* The binary to use, or `null` when there is nothing yet and one must be
|
|
349
|
+
* downloaded.
|
|
350
|
+
*
|
|
351
|
+
* An override that does not exist is NOT silently skipped — it is an operator
|
|
352
|
+
* mistake, and falling through to a different binary than the one they named
|
|
353
|
+
* would hide it. The caller reports it and carries on with the default, which
|
|
354
|
+
* is the only safe direction: a typo must not stop a node from serving video.
|
|
355
|
+
*/
|
|
356
|
+
function chooseFfmpegBinary(input) {
|
|
357
|
+
const override = input.override?.trim() ?? "";
|
|
358
|
+
if (override.length > 0 && input.exists(override)) return {
|
|
359
|
+
origin: "override",
|
|
360
|
+
path: override
|
|
361
|
+
};
|
|
362
|
+
const bundled = bundledFfmpegPath(input.platform);
|
|
363
|
+
if (bundled !== null && input.exists(bundled)) return {
|
|
364
|
+
origin: "bundled",
|
|
365
|
+
path: bundled
|
|
366
|
+
};
|
|
367
|
+
if (input.exists(input.downloadedPath)) return {
|
|
368
|
+
origin: "downloaded",
|
|
369
|
+
path: input.downloadedPath
|
|
370
|
+
};
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
/** The image-provided path for a platform, or `null` where we ship no image. */
|
|
374
|
+
function bundledFfmpegPath(platform) {
|
|
375
|
+
return platform === "linux" ? BUNDLED_FFMPEG_PATH : null;
|
|
376
|
+
}
|
|
377
|
+
//#endregion
|
|
188
378
|
//#region src/deps/ffmpeg-downloader.ts
|
|
189
379
|
/**
|
|
190
|
-
*
|
|
380
|
+
* The ffmpeg binary a node runs, and the static build we download when no image
|
|
381
|
+
* provided one.
|
|
191
382
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
383
|
+
* Which binary, and why it is never the host's, is in `ffmpeg-binary-source.ts`.
|
|
384
|
+
* This module is the download half: the URLs, and the resolution that reports
|
|
385
|
+
* what it picked.
|
|
195
386
|
*
|
|
196
|
-
*
|
|
197
|
-
* https://
|
|
387
|
+
* Sources for the download:
|
|
388
|
+
* - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
|
|
389
|
+
* - macOS: https://www.osxexperts.net/
|
|
198
390
|
*/
|
|
199
391
|
var FFMPEG_VERSION = "7.1";
|
|
200
392
|
function getFfmpegDownloadUrl(platform, arch) {
|
|
@@ -232,21 +424,48 @@ function getFfmpegArchiveInfo(platform) {
|
|
|
232
424
|
}
|
|
233
425
|
}
|
|
234
426
|
/**
|
|
235
|
-
* Ensure ffmpeg binary is available
|
|
236
|
-
*
|
|
427
|
+
* Ensure an ffmpeg binary WE provide is available on this node, and report
|
|
428
|
+
* which one and what it can do.
|
|
429
|
+
*
|
|
430
|
+
* Order: the operator's voluntary override → the one our image installed → a
|
|
431
|
+
* copy we already downloaded → download it. The system PATH is not in that
|
|
432
|
+
* list and must never be: see `ffmpeg-binary-source.ts`.
|
|
433
|
+
*
|
|
434
|
+
* The line it logs is the point as much as the path is. "Which ffmpeg am I
|
|
435
|
+
* running, and does it have vaapi" had no answer anywhere in the product, and
|
|
436
|
+
* that is what let a hardware-incapable binary serve every transcode on a hub
|
|
437
|
+
* with a working iGPU for months, silently in software.
|
|
237
438
|
*/
|
|
238
439
|
async function ensureFfmpeg(dataDir, logger) {
|
|
239
440
|
const depsDir = (0, node_path.join)(dataDir, "deps");
|
|
240
441
|
const platform = process.platform;
|
|
241
442
|
const arch = process.arch;
|
|
242
|
-
const
|
|
243
|
-
|
|
443
|
+
const ext = platform === "win32" ? ".exe" : "";
|
|
444
|
+
const downloadedPath = (0, node_path.join)(depsDir, `ffmpeg${ext}`);
|
|
445
|
+
const override = process.env[FFMPEG_PATH_ENV];
|
|
446
|
+
const chosen = chooseFfmpegBinary({
|
|
447
|
+
platform,
|
|
448
|
+
downloadedPath,
|
|
449
|
+
override,
|
|
450
|
+
exists: node_fs.existsSync
|
|
451
|
+
});
|
|
452
|
+
if (override !== void 0 && override.trim().length > 0 && chosen?.origin !== "override") logger.error(`${FFMPEG_PATH_ENV} names a binary that does not exist — ignoring it`, { meta: { [FFMPEG_PATH_ENV]: override } });
|
|
453
|
+
const path = chosen?.path ?? await downloadBinary({
|
|
244
454
|
name: "ffmpeg",
|
|
455
|
+
url: getFfmpegDownloadUrl(platform, arch),
|
|
245
456
|
targetDir: depsDir,
|
|
246
|
-
|
|
457
|
+
targetName: `ffmpeg${ext}`,
|
|
247
458
|
logger,
|
|
248
|
-
...
|
|
459
|
+
...getFfmpegArchiveInfo(platform)
|
|
249
460
|
});
|
|
461
|
+
const capabilities = await probeFfmpegBinary(path);
|
|
462
|
+
logger.info("ffmpeg binary resolved", { meta: {
|
|
463
|
+
path,
|
|
464
|
+
origin: chosen?.origin ?? "downloaded",
|
|
465
|
+
version: capabilities.version,
|
|
466
|
+
hwaccels: [...capabilities.hwaccels].join(",") || "none"
|
|
467
|
+
} });
|
|
468
|
+
return path;
|
|
250
469
|
}
|
|
251
470
|
//#endregion
|
|
252
471
|
//#region src/deps/python-downloader.ts
|
|
@@ -1751,9 +1970,11 @@ exports.getPythonDownloadUrl = getPythonDownloadUrl;
|
|
|
1751
1970
|
exports.installPythonPackages = installPythonPackages;
|
|
1752
1971
|
exports.installPythonRequirements = installPythonRequirements;
|
|
1753
1972
|
exports.nodeProcStatReader = nodeProcStatReader;
|
|
1973
|
+
exports.parseFfmpegCapabilities = parseFfmpegCapabilities;
|
|
1754
1974
|
exports.parseProcCpuSeconds = parseProcCpuSeconds;
|
|
1755
1975
|
exports.parseProcRssBytes = parseProcRssBytes;
|
|
1756
1976
|
exports.physicalRootOf = physicalRootOf;
|
|
1977
|
+
exports.probeFfmpegBinary = probeFfmpegBinary;
|
|
1757
1978
|
exports.readProcessCost = readProcessCost;
|
|
1758
1979
|
exports.resolveExportFingerprint = resolveExportFingerprint;
|
|
1759
1980
|
exports.signExpiringUrl = signExpiringUrl;
|
package/dist/node.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as Fmp4BoxSplitter, o as buildFfmpegArgs, t as canonicalHash, u as isSoftwareDecode } from "./canonical-hash-
|
|
1
|
+
import { n as Fmp4BoxSplitter, o as buildFfmpegArgs, t as canonicalHash, u as isSoftwareDecode } from "./canonical-hash-CPK2Dy60.mjs";
|
|
2
2
|
import { t as errMsg } from "./err-msg-IQTHeDzc.mjs";
|
|
3
3
|
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import * as fs from "node:fs";
|
|
@@ -7,7 +7,7 @@ import * as path$1 from "node:path";
|
|
|
7
7
|
import path, { basename, join } from "node:path";
|
|
8
8
|
import { pipeline } from "node:stream/promises";
|
|
9
9
|
import { Readable } from "node:stream";
|
|
10
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
10
|
+
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
12
|
//#region src/deps/binary-downloader.ts
|
|
13
13
|
/**
|
|
@@ -162,16 +162,208 @@ async function ensureBinary(opts) {
|
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
164
|
//#endregion
|
|
165
|
+
//#region src/ffmpeg/binary-capabilities.ts
|
|
166
|
+
/**
|
|
167
|
+
* What an ffmpeg binary can actually DO, asked of the binary itself.
|
|
168
|
+
*
|
|
169
|
+
* ## The failure this exists to end
|
|
170
|
+
*
|
|
171
|
+
* `ensureFfmpeg` prefers a downloaded STATIC build over the system one, and a
|
|
172
|
+
* static build is compiled for portability: no vendor libraries, no VAAPI, no
|
|
173
|
+
* QSV. On a machine with a working Intel iGPU that is precisely backwards, and
|
|
174
|
+
* the symptom does not say so. Measured on the hub, 2026-09-18:
|
|
175
|
+
*
|
|
176
|
+
* ```
|
|
177
|
+
* /data/deps/ffmpeg 7.0.2 johnvansickle static hwaccels: vdpau
|
|
178
|
+
* /usr/bin/ffmpeg 6.1.1 Ubuntu, in the image hwaccels: vdpau cuda vaapi qsv drm opencl vulkan
|
|
179
|
+
* ```
|
|
180
|
+
*
|
|
181
|
+
* Every consumer that went through `ensureFfmpeg` — the broker's egress and
|
|
182
|
+
* derived transcodes, the WebRTC transcode leg, the camera grid — asked for
|
|
183
|
+
* `-hwaccel vaapi`, got `Device creation failed: -12`, and fell back to
|
|
184
|
+
* software. `-12` is what fftools prints when `av_hwdevice_ctx_alloc` returns
|
|
185
|
+
* NULL because the type is not in the build: the NAME parses, so the request
|
|
186
|
+
* looks valid and the device simply cannot exist. It reads as a resource
|
|
187
|
+
* problem and is a build property.
|
|
188
|
+
*
|
|
189
|
+
* ## Why asking the kernel was not enough
|
|
190
|
+
*
|
|
191
|
+
* The existing hardware resolver answers from `/dev/dri` presence. That is a
|
|
192
|
+
* correct description of the NODE, and it is what `node-av` needs — the
|
|
193
|
+
* detection workers were on VAAPI the whole time. It says nothing about the
|
|
194
|
+
* separate binary a child will be spawned with, and it was being read as
|
|
195
|
+
* though it did.
|
|
196
|
+
*
|
|
197
|
+
* So: two questions, two answers. What does this node HAVE (the resolver), and
|
|
198
|
+
* what can this BINARY use (here). A backend needs both.
|
|
199
|
+
*/
|
|
200
|
+
/** How long a probe may take before the binary counts as unusable. */
|
|
201
|
+
var PROBE_TIMEOUT_MS = 5e3;
|
|
202
|
+
function run(path, args) {
|
|
203
|
+
return new Promise((resolve) => {
|
|
204
|
+
execFile(path, [...args], { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
|
|
205
|
+
resolve(error ? "" : stdout);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* `-hwaccels` prints a header line and then one name per line. Anything with a
|
|
211
|
+
* space in it is prose, not a name.
|
|
212
|
+
*/
|
|
213
|
+
function parseNames(output) {
|
|
214
|
+
const names = /* @__PURE__ */ new Set();
|
|
215
|
+
for (const raw of output.split("\n")) {
|
|
216
|
+
const line = raw.trim();
|
|
217
|
+
if (line.length === 0 || line.includes(" ")) continue;
|
|
218
|
+
names.add(line);
|
|
219
|
+
}
|
|
220
|
+
return names;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* `-encoders` prints a LEGEND and then a table, separated by a dashed line:
|
|
224
|
+
*
|
|
225
|
+
* ```
|
|
226
|
+
* Encoders:
|
|
227
|
+
* V..... = Video
|
|
228
|
+
* .F.... = Frame-level multithreading
|
|
229
|
+
* ------
|
|
230
|
+
* V....D h264_vaapi H.264/AVC (VAAPI) (codec h264)
|
|
231
|
+
* ```
|
|
232
|
+
*
|
|
233
|
+
* The legend rows have the SAME leading flag shape as the table rows, so a
|
|
234
|
+
* pattern alone matches ` V..... = Video` and yields `=` as an encoder name.
|
|
235
|
+
* Verified against the hub's own output on 2026-09-18, which is how that was
|
|
236
|
+
* caught. So the separator is the parser's state: nothing counts before it.
|
|
237
|
+
*/
|
|
238
|
+
function parseEncoders(output) {
|
|
239
|
+
const names = /* @__PURE__ */ new Set();
|
|
240
|
+
let inTable = false;
|
|
241
|
+
for (const raw of output.split("\n")) {
|
|
242
|
+
if (!inTable) {
|
|
243
|
+
if (raw.trim().startsWith("---")) inTable = true;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const match = /^\s[VAS][.F][.S][.X][.B][.D]\s+(\S+)/.exec(raw);
|
|
247
|
+
if (match?.[1] !== void 0) names.add(match[1]);
|
|
248
|
+
}
|
|
249
|
+
return names;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Turn three raw outputs into capabilities. Separated from the spawning so the
|
|
253
|
+
* parsers can be held against what ffmpeg REALLY prints — which is how the
|
|
254
|
+
* legend rows were caught being read as encoder names.
|
|
255
|
+
*/
|
|
256
|
+
function parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut) {
|
|
257
|
+
return {
|
|
258
|
+
path,
|
|
259
|
+
version: versionOut.split("\n")[0]?.trim() ?? "",
|
|
260
|
+
hwaccels: parseNames(hwaccelOut),
|
|
261
|
+
encoders: parseEncoders(encoderOut)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** Ask one binary what it can do. Never throws — an unusable binary answers empty. */
|
|
265
|
+
async function probeFfmpegBinary(path) {
|
|
266
|
+
const [versionOut, hwaccelOut, encoderOut] = await Promise.all([
|
|
267
|
+
run(path, ["-hide_banner", "-version"]),
|
|
268
|
+
run(path, ["-hide_banner", "-hwaccels"]),
|
|
269
|
+
run(path, ["-hide_banner", "-encoders"])
|
|
270
|
+
]);
|
|
271
|
+
return parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut);
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/deps/ffmpeg-binary-source.ts
|
|
275
|
+
/**
|
|
276
|
+
* WHICH ffmpeg a node runs, and why it is never the host's.
|
|
277
|
+
*
|
|
278
|
+
* CamStack spawns ffmpeg for the broker's egress and derived transcodes, the
|
|
279
|
+
* WebRTC transcode leg, the recorder, snapshots and the camera grid. What that
|
|
280
|
+
* binary is compiled with decides whether any of them can use the machine's
|
|
281
|
+
* hardware, so it is not a detail to leave to whatever the host happens to have
|
|
282
|
+
* on its PATH:
|
|
283
|
+
*
|
|
284
|
+
* - a host binary is an unknown version with unknown vendor libraries, and it
|
|
285
|
+
* changes under us on any host update;
|
|
286
|
+
* - a self-hosted NVR cannot assume the host has ffmpeg at all.
|
|
287
|
+
*
|
|
288
|
+
* So the binary is always OURS. Two ways it can be ours, and which one applies
|
|
289
|
+
* is a property of where the node runs — not of an environment variable:
|
|
290
|
+
*
|
|
291
|
+
* | Node | Binary |
|
|
292
|
+
* | --- | --- |
|
|
293
|
+
* | our container image | {@link BUNDLED_FFMPEG_PATH}, installed by the image |
|
|
294
|
+
* | anything else (a native macOS agent) | the static build we download into `<dataDir>/deps` |
|
|
295
|
+
*
|
|
296
|
+
* `BUNDLED_FFMPEG_PATH` is a CamStack-owned path that only our image creates
|
|
297
|
+
* (a link to the ffmpeg the image installs). That is what makes "is this ours?"
|
|
298
|
+
* answerable: `/usr/bin/ffmpeg` is ours INSIDE the image and the host's outside
|
|
299
|
+
* it, and nothing in the process can tell those apart. A path only we ever
|
|
300
|
+
* write can.
|
|
301
|
+
*
|
|
302
|
+
* The image build is the reason to prefer it over the download: the image
|
|
303
|
+
* installs Ubuntu's ffmpeg next to the Intel media driver, so it carries VAAPI
|
|
304
|
+
* and QSV. The portable static builds carry neither — measured on the hub,
|
|
305
|
+
* 2026-09-18:
|
|
306
|
+
*
|
|
307
|
+
* ```
|
|
308
|
+
* /data/deps/ffmpeg 7.0.2 johnvansickle static hwaccels: vdpau
|
|
309
|
+
* /usr/bin/ffmpeg 6.1.1 Ubuntu, in the image hwaccels: vdpau cuda vaapi qsv drm opencl vulkan
|
|
310
|
+
* ```
|
|
311
|
+
*
|
|
312
|
+
* A child spawned with the first one asks for `-hwaccel vaapi` and gets
|
|
313
|
+
* `Device creation failed: -12` — the name parses, the device cannot exist —
|
|
314
|
+
* then transcodes in software with nothing naming the cause.
|
|
315
|
+
*
|
|
316
|
+
* {@link FFMPEG_PATH_ENV} is an operator's VOLUNTARY override, and only that:
|
|
317
|
+
* unset is the normal, correct state on every node, and nothing in the product
|
|
318
|
+
* sets it.
|
|
319
|
+
*/
|
|
320
|
+
/** Where our container image puts the ffmpeg it installs. Only the image writes this. */
|
|
321
|
+
var BUNDLED_FFMPEG_PATH = "/opt/camstack/bin/ffmpeg";
|
|
322
|
+
/** Operator override. Unset on every node unless a human deliberately set it. */
|
|
323
|
+
var FFMPEG_PATH_ENV = "CAMSTACK_FFMPEG_PATH";
|
|
324
|
+
/**
|
|
325
|
+
* The binary to use, or `null` when there is nothing yet and one must be
|
|
326
|
+
* downloaded.
|
|
327
|
+
*
|
|
328
|
+
* An override that does not exist is NOT silently skipped — it is an operator
|
|
329
|
+
* mistake, and falling through to a different binary than the one they named
|
|
330
|
+
* would hide it. The caller reports it and carries on with the default, which
|
|
331
|
+
* is the only safe direction: a typo must not stop a node from serving video.
|
|
332
|
+
*/
|
|
333
|
+
function chooseFfmpegBinary(input) {
|
|
334
|
+
const override = input.override?.trim() ?? "";
|
|
335
|
+
if (override.length > 0 && input.exists(override)) return {
|
|
336
|
+
origin: "override",
|
|
337
|
+
path: override
|
|
338
|
+
};
|
|
339
|
+
const bundled = bundledFfmpegPath(input.platform);
|
|
340
|
+
if (bundled !== null && input.exists(bundled)) return {
|
|
341
|
+
origin: "bundled",
|
|
342
|
+
path: bundled
|
|
343
|
+
};
|
|
344
|
+
if (input.exists(input.downloadedPath)) return {
|
|
345
|
+
origin: "downloaded",
|
|
346
|
+
path: input.downloadedPath
|
|
347
|
+
};
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
/** The image-provided path for a platform, or `null` where we ship no image. */
|
|
351
|
+
function bundledFfmpegPath(platform) {
|
|
352
|
+
return platform === "linux" ? BUNDLED_FFMPEG_PATH : null;
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
165
355
|
//#region src/deps/ffmpeg-downloader.ts
|
|
166
356
|
/**
|
|
167
|
-
*
|
|
357
|
+
* The ffmpeg binary a node runs, and the static build we download when no image
|
|
358
|
+
* provided one.
|
|
168
359
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
360
|
+
* Which binary, and why it is never the host's, is in `ffmpeg-binary-source.ts`.
|
|
361
|
+
* This module is the download half: the URLs, and the resolution that reports
|
|
362
|
+
* what it picked.
|
|
172
363
|
*
|
|
173
|
-
*
|
|
174
|
-
* https://
|
|
364
|
+
* Sources for the download:
|
|
365
|
+
* - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
|
|
366
|
+
* - macOS: https://www.osxexperts.net/
|
|
175
367
|
*/
|
|
176
368
|
var FFMPEG_VERSION = "7.1";
|
|
177
369
|
function getFfmpegDownloadUrl(platform, arch) {
|
|
@@ -209,21 +401,48 @@ function getFfmpegArchiveInfo(platform) {
|
|
|
209
401
|
}
|
|
210
402
|
}
|
|
211
403
|
/**
|
|
212
|
-
* Ensure ffmpeg binary is available
|
|
213
|
-
*
|
|
404
|
+
* Ensure an ffmpeg binary WE provide is available on this node, and report
|
|
405
|
+
* which one and what it can do.
|
|
406
|
+
*
|
|
407
|
+
* Order: the operator's voluntary override → the one our image installed → a
|
|
408
|
+
* copy we already downloaded → download it. The system PATH is not in that
|
|
409
|
+
* list and must never be: see `ffmpeg-binary-source.ts`.
|
|
410
|
+
*
|
|
411
|
+
* The line it logs is the point as much as the path is. "Which ffmpeg am I
|
|
412
|
+
* running, and does it have vaapi" had no answer anywhere in the product, and
|
|
413
|
+
* that is what let a hardware-incapable binary serve every transcode on a hub
|
|
414
|
+
* with a working iGPU for months, silently in software.
|
|
214
415
|
*/
|
|
215
416
|
async function ensureFfmpeg(dataDir, logger) {
|
|
216
417
|
const depsDir = join(dataDir, "deps");
|
|
217
418
|
const platform = process.platform;
|
|
218
419
|
const arch = process.arch;
|
|
219
|
-
const
|
|
220
|
-
|
|
420
|
+
const ext = platform === "win32" ? ".exe" : "";
|
|
421
|
+
const downloadedPath = join(depsDir, `ffmpeg${ext}`);
|
|
422
|
+
const override = process.env[FFMPEG_PATH_ENV];
|
|
423
|
+
const chosen = chooseFfmpegBinary({
|
|
424
|
+
platform,
|
|
425
|
+
downloadedPath,
|
|
426
|
+
override,
|
|
427
|
+
exists: existsSync
|
|
428
|
+
});
|
|
429
|
+
if (override !== void 0 && override.trim().length > 0 && chosen?.origin !== "override") logger.error(`${FFMPEG_PATH_ENV} names a binary that does not exist — ignoring it`, { meta: { [FFMPEG_PATH_ENV]: override } });
|
|
430
|
+
const path = chosen?.path ?? await downloadBinary({
|
|
221
431
|
name: "ffmpeg",
|
|
432
|
+
url: getFfmpegDownloadUrl(platform, arch),
|
|
222
433
|
targetDir: depsDir,
|
|
223
|
-
|
|
434
|
+
targetName: `ffmpeg${ext}`,
|
|
224
435
|
logger,
|
|
225
|
-
...
|
|
436
|
+
...getFfmpegArchiveInfo(platform)
|
|
226
437
|
});
|
|
438
|
+
const capabilities = await probeFfmpegBinary(path);
|
|
439
|
+
logger.info("ffmpeg binary resolved", { meta: {
|
|
440
|
+
path,
|
|
441
|
+
origin: chosen?.origin ?? "downloaded",
|
|
442
|
+
version: capabilities.version,
|
|
443
|
+
hwaccels: [...capabilities.hwaccels].join(",") || "none"
|
|
444
|
+
} });
|
|
445
|
+
return path;
|
|
227
446
|
}
|
|
228
447
|
//#endregion
|
|
229
448
|
//#region src/deps/python-downloader.ts
|
|
@@ -1705,4 +1924,4 @@ function resolveExportFingerprint(input) {
|
|
|
1705
1924
|
return input.persisted ?? input.fresh;
|
|
1706
1925
|
}
|
|
1707
1926
|
//#endregion
|
|
1708
|
-
export { ChildCostRegistry, FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, NO_COST_CLAIM, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, containsOrEquals, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, nodeProcStatReader, parseProcCpuSeconds, parseProcRssBytes, physicalRootOf, readProcessCost, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
|
|
1927
|
+
export { ChildCostRegistry, FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, NO_COST_CLAIM, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, containsOrEquals, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, nodeProcStatReader, parseFfmpegCapabilities, parseProcCpuSeconds, parseProcRssBytes, physicalRootOf, probeFfmpegBinary, readProcessCost, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
|
|
@@ -1362,6 +1362,17 @@ var CAM_PROFILE_ORDER = [
|
|
|
1362
1362
|
"mid",
|
|
1363
1363
|
"low"
|
|
1364
1364
|
];
|
|
1365
|
+
/**
|
|
1366
|
+
* Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
|
|
1367
|
+
*
|
|
1368
|
+
* `auto` is a REAL value, not an absent one — it means "resolve it by the rule
|
|
1369
|
+
* that consumer owns". It lives beside `CamProfile` because a rung is the only
|
|
1370
|
+
* operator-facing way to name a stream: a cam-stream id is broker-internal and
|
|
1371
|
+
* moves when a slot is reassigned, so a stored id silently stops meaning what
|
|
1372
|
+
* the operator picked. Today's one user is the audio analyser, whose rule is
|
|
1373
|
+
* "the lowest AVAILABLE native rung" (D537).
|
|
1374
|
+
*/
|
|
1375
|
+
var AudioStreamPickSchema = z.union([CamProfileSchema, z.literal("auto")]);
|
|
1365
1376
|
var CamStreamKindSchema = z.enum([
|
|
1366
1377
|
"pull-rtsp",
|
|
1367
1378
|
"pull-rtmp",
|
|
@@ -3880,7 +3891,8 @@ function createDeviceProxy(api, binding, opts) {
|
|
|
3880
3891
|
getIceCandidates: (input) => dispatch("webrtc-session", "webrtcSession", "getIceCandidates", "query", input),
|
|
3881
3892
|
closeSession: (input) => dispatch("webrtc-session", "webrtcSession", "closeSession", "mutation", input),
|
|
3882
3893
|
hasAdaptiveBitrate: (input) => dispatch("webrtc-session", "webrtcSession", "hasAdaptiveBitrate", "query", input),
|
|
3883
|
-
getSessionState: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionState", "query", input)
|
|
3894
|
+
getSessionState: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionState", "query", input),
|
|
3895
|
+
getSessionDebug: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionDebug", "query", input)
|
|
3884
3896
|
},
|
|
3885
3897
|
zoneAnalytics: {
|
|
3886
3898
|
getCurrentSnapshot: (input) => dispatch("zone-analytics", "zoneAnalytics", "getCurrentSnapshot", "query", input),
|
|
@@ -4320,4 +4332,4 @@ function sleepCancellable(ms, signal) {
|
|
|
4320
4332
|
});
|
|
4321
4333
|
}
|
|
4322
4334
|
//#endregion
|
|
4323
|
-
export {
|
|
4335
|
+
export { BrokerStatusSchema as $, DeviceType as A, emitReadiness as At, systemMethod as B, expandAudioChunkToF32le as Bt, customAction as C, DATAPLANE_SECRET_HEADER as Ct, ChargingStatus as D, normalizeAddonInitResult as Dt, deviceCustomAction as E, BaseAddon as Et, event as F, collectHydratedFieldValues as Ft, ReadinessRegistry as G, nodePin as H, expandCapMethods as I, hydrateSchema as It, readinessKey as J, ReadinessTimeoutError as K, isDeviceConfigCap as L, resolveHydratedFieldValue as Lt, DEFAULT_RUNTIME_STATE_DURABILITY as M, WELL_KNOWN_TABS as Mt, DEVICE_SETTINGS_CONTRIBUTION_METHODS as N, WELL_KNOWN_TAB_MAP as Nt, DeviceFeature as O, createDurableState as Ot, DEVICE_STATUS_METHOD as P, collectHydratedFieldEntries as Pt, BrokerStatsSchema as Q, method as R, AUDIO_CHUNK_FORMATS as Rt, DEVICE_CHILDREN_BATCH_MAX as S, selectAssignedProfileSlots as St, describeCustomActions as T, DisposerChain as Tt, readNodePin as U, CAP_NODE_PIN_CONTEXT_KEY as V, isAudioChunkFormat as Vt, toNodeId as W, AudioChunkFormatSchema as X, scopeKey as Y, AudioStreamPickSchema as Z, createMirrorSource as _, SubscribeFramesInputSchema as _t, asJsonObject as a, DecodedAudioChunkSchema as at, deviceOpsCapability as b, makeSourceBrokerId as bt, parseJsonArray as c, FrameHandleFormatSchema as ct, BOOT_RECOVERY_BACKOFF_MS as d, ProfileSlotSchema as dt, CAM_PROFILE_ORDER as et, DEVICE_SCOPED_CAPS as f, ProfileSlotStatusSchema as ft, createLazyTrpcSource as g, SubscribeAudioChunksResultSchema as gt, createEventBusSliceSource as h, SubscribeAudioChunksInputSchema as ht, asJsonArray as i, CameraStreamSchema as it, adminUiCapability as j, isEvent as jt, DeviceRole as k, createEvent as kt, parseJsonObject as l, FrameHandleSchema as lt, createDeviceProxy as m, StreamSourceSchema as mt, sleepCancellable as n, CamStreamKindSchema as nt, asNumber as o, DecodedFrameSchema as ot, isDeviceScopedCap as p, StreamSourceEntrySchema$1 as pt, emitDownForOwnedCaps as q, asBoolean as r, CamStreamResolutionSchema as rt, asString as s, EncodedPacketSchema as st, sleep as t, CamProfileSchema as tt, parseJsonUnknown as u, ProfileRtspEntrySchema as ut, createSliceHandle as v, SubscribeFramesResultSchema as vt, defineCustomActions as w, SHARE_VIEW_KINDS as wt, viewerUiCapability as x, parseProfileBrokerId as xt, RawStateResultSchema as y, makeProfileBrokerId as yt, resolveCapMount as z, audioChunkBytesPerSample as zt };
|
|
@@ -1362,6 +1362,17 @@ var CAM_PROFILE_ORDER = [
|
|
|
1362
1362
|
"mid",
|
|
1363
1363
|
"low"
|
|
1364
1364
|
];
|
|
1365
|
+
/**
|
|
1366
|
+
* Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
|
|
1367
|
+
*
|
|
1368
|
+
* `auto` is a REAL value, not an absent one — it means "resolve it by the rule
|
|
1369
|
+
* that consumer owns". It lives beside `CamProfile` because a rung is the only
|
|
1370
|
+
* operator-facing way to name a stream: a cam-stream id is broker-internal and
|
|
1371
|
+
* moves when a slot is reassigned, so a stored id silently stops meaning what
|
|
1372
|
+
* the operator picked. Today's one user is the audio analyser, whose rule is
|
|
1373
|
+
* "the lowest AVAILABLE native rung" (D537).
|
|
1374
|
+
*/
|
|
1375
|
+
var AudioStreamPickSchema = zod.z.union([CamProfileSchema, zod.z.literal("auto")]);
|
|
1365
1376
|
var CamStreamKindSchema = zod.z.enum([
|
|
1366
1377
|
"pull-rtsp",
|
|
1367
1378
|
"pull-rtmp",
|
|
@@ -3880,7 +3891,8 @@ function createDeviceProxy(api, binding, opts) {
|
|
|
3880
3891
|
getIceCandidates: (input) => dispatch("webrtc-session", "webrtcSession", "getIceCandidates", "query", input),
|
|
3881
3892
|
closeSession: (input) => dispatch("webrtc-session", "webrtcSession", "closeSession", "mutation", input),
|
|
3882
3893
|
hasAdaptiveBitrate: (input) => dispatch("webrtc-session", "webrtcSession", "hasAdaptiveBitrate", "query", input),
|
|
3883
|
-
getSessionState: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionState", "query", input)
|
|
3894
|
+
getSessionState: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionState", "query", input),
|
|
3895
|
+
getSessionDebug: (input) => dispatch("webrtc-session", "webrtcSession", "getSessionDebug", "query", input)
|
|
3884
3896
|
},
|
|
3885
3897
|
zoneAnalytics: {
|
|
3886
3898
|
getCurrentSnapshot: (input) => dispatch("zone-analytics", "zoneAnalytics", "getCurrentSnapshot", "query", input),
|
|
@@ -4332,6 +4344,12 @@ Object.defineProperty(exports, "AudioChunkFormatSchema", {
|
|
|
4332
4344
|
return AudioChunkFormatSchema;
|
|
4333
4345
|
}
|
|
4334
4346
|
});
|
|
4347
|
+
Object.defineProperty(exports, "AudioStreamPickSchema", {
|
|
4348
|
+
enumerable: true,
|
|
4349
|
+
get: function() {
|
|
4350
|
+
return AudioStreamPickSchema;
|
|
4351
|
+
}
|
|
4352
|
+
});
|
|
4335
4353
|
Object.defineProperty(exports, "BOOT_RECOVERY_BACKOFF_MS", {
|
|
4336
4354
|
enumerable: true,
|
|
4337
4355
|
get: function() {
|