@camstack/types 1.2.223 → 1.2.225

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.
@@ -57,6 +57,7 @@ function buildInputArgs(input, decodeHwAccel) {
57
57
  const args = [];
58
58
  if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
59
59
  if (input.extraArgs?.length) args.push(...input.extraArgs);
60
+ if (input.decodeThreadCount !== void 0 && input.decodeThreadCount > 0) args.push("-threads", String(input.decodeThreadCount));
60
61
  if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
61
62
  if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
62
63
  if (input.lowDelay === true) args.push("-flags", "low_delay");
@@ -327,7 +328,7 @@ function buildFfmpegArgs(inv) {
327
328
  ...(inv.extraInputs ?? []).flatMap((extra) => buildInputArgs(extra, inv.decodeHwAccel)),
328
329
  ...graph ? ["-filter_complex", graph.graph] : []
329
330
  ];
330
- const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
331
+ const threadArgs = [...inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [], ...inv.filterThreadCount !== void 0 && inv.filterThreadCount > 0 ? ["-filter_threads", String(inv.filterThreadCount)] : []];
331
332
  const maps = resolveStreamMaps(inv);
332
333
  const videoMapArgs = maps.video ? ["-map", maps.video] : [];
333
334
  const audioMapArgs = maps.audio ? ["-map", maps.audio] : [];
@@ -57,6 +57,7 @@ function buildInputArgs(input, decodeHwAccel) {
57
57
  const args = [];
58
58
  if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
59
59
  if (input.extraArgs?.length) args.push(...input.extraArgs);
60
+ if (input.decodeThreadCount !== void 0 && input.decodeThreadCount > 0) args.push("-threads", String(input.decodeThreadCount));
60
61
  if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
61
62
  if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
62
63
  if (input.lowDelay === true) args.push("-flags", "low_delay");
@@ -327,7 +328,7 @@ function buildFfmpegArgs(inv) {
327
328
  ...(inv.extraInputs ?? []).flatMap((extra) => buildInputArgs(extra, inv.decodeHwAccel)),
328
329
  ...graph ? ["-filter_complex", graph.graph] : []
329
330
  ];
330
- const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
331
+ const threadArgs = [...inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [], ...inv.filterThreadCount !== void 0 && inv.filterThreadCount > 0 ? ["-filter_threads", String(inv.filterThreadCount)] : []];
331
332
  const maps = resolveStreamMaps(inv);
332
333
  const videoMapArgs = maps.video ? ["-map", maps.video] : [];
333
334
  const audioMapArgs = maps.audio ? ["-map", maps.audio] : [];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * WHICH ffmpeg a node runs, and why it is never the host's.
3
+ *
4
+ * CamStack spawns ffmpeg for the broker's egress and derived transcodes, the
5
+ * WebRTC transcode leg, the recorder, snapshots and the camera grid. What that
6
+ * binary is compiled with decides whether any of them can use the machine's
7
+ * hardware, so it is not a detail to leave to whatever the host happens to have
8
+ * on its PATH:
9
+ *
10
+ * - a host binary is an unknown version with unknown vendor libraries, and it
11
+ * changes under us on any host update;
12
+ * - a self-hosted NVR cannot assume the host has ffmpeg at all.
13
+ *
14
+ * So the binary is always OURS. Two ways it can be ours, and which one applies
15
+ * is a property of where the node runs — not of an environment variable:
16
+ *
17
+ * | Node | Binary |
18
+ * | --- | --- |
19
+ * | our container image | {@link BUNDLED_FFMPEG_PATH}, installed by the image |
20
+ * | anything else (a native macOS agent) | the static build we download into `<dataDir>/deps` |
21
+ *
22
+ * `BUNDLED_FFMPEG_PATH` is a CamStack-owned path that only our image creates
23
+ * (a link to the ffmpeg the image installs). That is what makes "is this ours?"
24
+ * answerable: `/usr/bin/ffmpeg` is ours INSIDE the image and the host's outside
25
+ * it, and nothing in the process can tell those apart. A path only we ever
26
+ * write can.
27
+ *
28
+ * The image build is the reason to prefer it over the download: the image
29
+ * installs Ubuntu's ffmpeg next to the Intel media driver, so it carries VAAPI
30
+ * and QSV. The portable static builds carry neither — measured on the hub,
31
+ * 2026-09-18:
32
+ *
33
+ * ```
34
+ * /data/deps/ffmpeg 7.0.2 johnvansickle static hwaccels: vdpau
35
+ * /usr/bin/ffmpeg 6.1.1 Ubuntu, in the image hwaccels: vdpau cuda vaapi qsv drm opencl vulkan
36
+ * ```
37
+ *
38
+ * A child spawned with the first one asks for `-hwaccel vaapi` and gets
39
+ * `Device creation failed: -12` — the name parses, the device cannot exist —
40
+ * then transcodes in software with nothing naming the cause.
41
+ *
42
+ * {@link FFMPEG_PATH_ENV} is an operator's VOLUNTARY override, and only that:
43
+ * unset is the normal, correct state on every node, and nothing in the product
44
+ * sets it.
45
+ */
46
+ /** Where our container image puts the ffmpeg it installs. Only the image writes this. */
47
+ export declare const BUNDLED_FFMPEG_PATH = "/opt/camstack/bin/ffmpeg";
48
+ /** Operator override. Unset on every node unless a human deliberately set it. */
49
+ export declare const FFMPEG_PATH_ENV = "CAMSTACK_FFMPEG_PATH";
50
+ export type FfmpegBinaryOrigin = 'override' | 'bundled' | 'downloaded';
51
+ export interface FfmpegBinaryChoice {
52
+ readonly origin: FfmpegBinaryOrigin;
53
+ readonly path: string;
54
+ }
55
+ export interface FfmpegBinarySourceInput {
56
+ readonly platform: string;
57
+ /** `<dataDir>/deps/ffmpeg`, where a downloaded copy lands. */
58
+ readonly downloadedPath: string;
59
+ /** The raw value of {@link FFMPEG_PATH_ENV}, or null/empty when unset. */
60
+ readonly override: string | null | undefined;
61
+ readonly exists: (path: string) => boolean;
62
+ }
63
+ /**
64
+ * The binary to use, or `null` when there is nothing yet and one must be
65
+ * downloaded.
66
+ *
67
+ * An override that does not exist is NOT silently skipped — it is an operator
68
+ * mistake, and falling through to a different binary than the one they named
69
+ * would hide it. The caller reports it and carries on with the default, which
70
+ * is the only safe direction: a typo must not stop a node from serving video.
71
+ */
72
+ export declare function chooseFfmpegBinary(input: FfmpegBinarySourceInput): FfmpegBinaryChoice | null;
73
+ /** The image-provided path for a platform, or `null` where we ship no image. */
74
+ export declare function bundledFfmpegPath(platform: string): string | null;
@@ -6,7 +6,16 @@ export declare function getFfmpegArchiveInfo(platform: string): {
6
6
  archiveInnerPath: string;
7
7
  };
8
8
  /**
9
- * Ensure ffmpeg binary is available.
10
- * Checks: deps dir → system PATH → download.
9
+ * Ensure an ffmpeg binary WE provide is available on this node, and report
10
+ * which one and what it can do.
11
+ *
12
+ * Order: the operator's voluntary override → the one our image installed → a
13
+ * copy we already downloaded → download it. The system PATH is not in that
14
+ * list and must never be: see `ffmpeg-binary-source.ts`.
15
+ *
16
+ * The line it logs is the point as much as the path is. "Which ffmpeg am I
17
+ * running, and does it have vaapi" had no answer anywhere in the product, and
18
+ * that is what let a hardware-incapable binary serve every transcode on a hub
19
+ * with a working iGPU for months, silently in software.
11
20
  */
12
21
  export declare function ensureFfmpeg(dataDir: string, logger: IScopedLogger): Promise<string>;
@@ -0,0 +1,31 @@
1
+ /** What one ffmpeg binary reports about itself. */
2
+ export interface FfmpegBinaryCapabilities {
3
+ readonly path: string;
4
+ /** First line of `-version`, for the log that names the choice. */
5
+ readonly version: string;
6
+ /** Names from `-hwaccels`, e.g. `vaapi`, `qsv`, `videotoolbox`. */
7
+ readonly hwaccels: ReadonlySet<string>;
8
+ /** Encoder names from `-encoders`, e.g. `h264_vaapi`, `h264_videotoolbox`. */
9
+ readonly encoders: ReadonlySet<string>;
10
+ }
11
+ /**
12
+ * Turn three raw outputs into capabilities. Separated from the spawning so the
13
+ * parsers can be held against what ffmpeg REALLY prints — which is how the
14
+ * legend rows were caught being read as encoder names.
15
+ */
16
+ export declare function parseFfmpegCapabilities(path: string, versionOut: string, hwaccelOut: string, encoderOut: string): FfmpegBinaryCapabilities;
17
+ /** Ask one binary what it can do. Never throws — an unusable binary answers empty. */
18
+ export declare function probeFfmpegBinary(path: string): Promise<FfmpegBinaryCapabilities>;
19
+ /**
20
+ * Pick the binary that can serve this node's hardware.
21
+ *
22
+ * `wanted` is what the NODE has, from the hardware resolver — the order it is
23
+ * given in is the order of preference. A candidate that offers any of them
24
+ * wins, earliest-wanted first; among equals the earliest CANDIDATE wins, so a
25
+ * caller states its own precedence by the order it passes them in.
26
+ *
27
+ * With nothing wanted, or nothing offering anything, the first candidate wins:
28
+ * a node with no hardware has no reason to prefer one binary over another, and
29
+ * "the first one" is the caller's existing precedence, unchanged.
30
+ */
31
+ export declare function pickCapableBinary(candidates: readonly FfmpegBinaryCapabilities[], wanted: readonly string[]): FfmpegBinaryCapabilities | null;
@@ -60,6 +60,24 @@ export interface FfmpegInputPlan {
60
60
  */
61
61
  readonly analyzeDurationUs?: number;
62
62
  readonly probeSizeBytes?: number;
63
+ /**
64
+ * `-threads` for the DECODER, emitted before `-i`.
65
+ *
66
+ * Distinct from {@link FfmpegInvocation.threadCount}, which lands after the
67
+ * video args and therefore bounds the ENCODER only. Nothing bounded the
68
+ * decoder at all until 2026-09-18, and that is where the threads were: left
69
+ * at auto, libavcodec creates `min(cores, 16)` frame threads per input, on a
70
+ * 20-core hub, per child.
71
+ *
72
+ * Measured the same day: a grid tile reading a 1080p sub-stream held 72
73
+ * threads; with this set to 1 and `filterThreadCount` to 1 it holds 26. The
74
+ * container's pid cgroup counts every one against a 2048 ceiling it was
75
+ * actively refusing at.
76
+ *
77
+ * A parallel decode is still a real thing — a 4K software decode at 25 fps
78
+ * cannot keep up single-threaded — so this is a number, not a flag.
79
+ */
80
+ readonly decodeThreadCount?: number;
63
81
  /**
64
82
  * A headerless stream of raw frames — `-f rawvideo` and the three facts that
65
83
  * demuxer cannot discover for itself.
@@ -389,6 +407,21 @@ export interface FfmpegInvocation {
389
407
  readonly audio: FfmpegAudioPlan;
390
408
  /** `0` = auto (omit `-threads` and let ffmpeg decide). */
391
409
  readonly threadCount: number;
410
+ /**
411
+ * `-filter_threads` — the size of libavfilter's OWN slice pool, which
412
+ * `-threads` does not touch.
413
+ *
414
+ * A separate number because it is a separate pool: ffmpeg sizes the filter
415
+ * pool to the core count independently of the codec threads, per graph. On
416
+ * this 20-core hub that is ~15 threads per graph doing nothing measurable on
417
+ * a 320x180-class output, and the container's pid cgroup counts every one of
418
+ * them. Measured on the hub 2026-09-18: a recorder passthrough went 20 → 5
419
+ * threads, a snapshot grab 35 → 4, an egress transcode 62 → 18, with
420
+ * `-threads` and this set together.
421
+ *
422
+ * Omitted when absent, so no shipped argv changes by adding the field.
423
+ */
424
+ readonly filterThreadCount?: number;
392
425
  /** Consumer output options, emitted verbatim after the encode block. */
393
426
  readonly outputArgs: readonly string[];
394
427
  readonly sink: FfmpegSink;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_sleep = require("./sleep-VlZxDyXq.js");
3
3
  const require_event_category = require("./event-category-BVfsrBYA.js");
4
- const require_canonical_hash = require("./canonical-hash-BS9dQmcQ.js");
4
+ const require_canonical_hash = require("./canonical-hash-DYsYm8is.js");
5
5
  const require_enums = require("./enums.js");
6
6
  const require_err_msg = require("./err-msg-COpsHMw2.js");
7
7
  let zod = require("zod");
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { $ as CAM_PROFILE_ORDER, A as DeviceType, At as isEvent, B as systemMethod, Bt as isAudioChunkFormat, C as customAction, Ct as SHARE_VIEW_KINDS, D as ChargingStatus, Dt as createDurableState, E as deviceCustomAction, Et as normalizeAddonInitResult, F as event, Ft as hydrateSchema, G as ReadinessRegistry, H as nodePin, I as expandCapMethods, It as resolveHydratedFieldValue, J as readinessKey, K as ReadinessTimeoutError, L as isDeviceConfigCap, Lt as AUDIO_CHUNK_FORMATS, M as DEFAULT_RUNTIME_STATE_DURABILITY, Mt as WELL_KNOWN_TAB_MAP, N as DEVICE_SETTINGS_CONTRIBUTION_METHODS, Nt as collectHydratedFieldEntries, O as DeviceFeature, Ot as createEvent, P as DEVICE_STATUS_METHOD, Pt as collectHydratedFieldValues, Q as BrokerStatusSchema, R as method, Rt as audioChunkBytesPerSample, S as DEVICE_CHILDREN_BATCH_MAX, St as DATAPLANE_SECRET_HEADER, T as describeCustomActions, Tt as BaseAddon, U as readNodePin, V as CAP_NODE_PIN_CONTEXT_KEY, W as toNodeId, X as AudioChunkFormatSchema, Y as scopeKey, Z as BrokerStatsSchema, _ as createMirrorSource, _t as SubscribeFramesResultSchema, a as asJsonObject, at as DecodedFrameSchema, b as deviceOpsCapability, bt as parseProfileBrokerId, c as parseJsonArray, ct as FrameHandleSchema, d as BOOT_RECOVERY_BACKOFF_MS, dt as ProfileSlotStatusSchema, et as CamProfileSchema, f as DEVICE_SCOPED_CAPS, ft as StreamSourceEntrySchema, g as createLazyTrpcSource, gt as SubscribeFramesInputSchema, h as createEventBusSliceSource, ht as SubscribeAudioChunksResultSchema, i as asJsonArray, it as DecodedAudioChunkSchema, j as adminUiCapability, jt as WELL_KNOWN_TABS, k as DeviceRole, kt as emitReadiness, l as parseJsonObject, lt as ProfileRtspEntrySchema, m as createDeviceProxy, mt as SubscribeAudioChunksInputSchema, n as sleepCancellable, nt as CamStreamResolutionSchema, o as asNumber, ot as EncodedPacketSchema, p as isDeviceScopedCap, pt as StreamSourceSchema, q as emitDownForOwnedCaps, r as asBoolean, rt as CameraStreamSchema, s as asString, st as FrameHandleFormatSchema, t as sleep, tt as CamStreamKindSchema, u as parseJsonUnknown, ut as ProfileSlotSchema, v as createSliceHandle, vt as makeProfileBrokerId, w as defineCustomActions, wt as DisposerChain, x as viewerUiCapability, xt as selectAssignedProfileSlots, y as RawStateResultSchema, yt as makeSourceBrokerId, z as resolveCapMount, zt as expandAudioChunkToF32le } from "./sleep-GU_us3DG.mjs";
2
2
  import { t as EventCategory } from "./event-category-BVDXG4tB.mjs";
3
- import { a as buildAudioArgs, c as buildVideoArgs, d as logBannerArgs, f as pickVideoEncoder, i as audioPlanFromEncodeProfile, l as invocationFromEncodeProfile, n as Fmp4BoxSplitter, o as buildFfmpegArgs, p as resolveStreamMaps, r as AUDIO_PRESETS, s as buildInputArgs, t as canonicalHash, u as isSoftwareDecode } from "./canonical-hash-C5pB-QV1.mjs";
3
+ import { a as buildAudioArgs, c as buildVideoArgs, d as logBannerArgs, f as pickVideoEncoder, i as audioPlanFromEncodeProfile, l as invocationFromEncodeProfile, n as Fmp4BoxSplitter, o as buildFfmpegArgs, p as resolveStreamMaps, r as AUDIO_PRESETS, s as buildInputArgs, t as canonicalHash, u as isSoftwareDecode } from "./canonical-hash-CvL03d3i.mjs";
4
4
  import { EventSourceType } from "./enums.mjs";
5
5
  import { t as errMsg } from "./err-msg-IQTHeDzc.mjs";
6
6
  import { z } from "zod";
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-BS9dQmcQ.js");
24
+ const require_canonical_hash = require("./canonical-hash-DYsYm8is.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
- * Download ffmpeg static build for the current platform.
380
+ * The ffmpeg binary a node runs, and the static build we download when no image
381
+ * provided one.
191
382
  *
192
- * Sources:
193
- * - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
194
- * - macOS: https://evermeet.cx/ffmpeg/ or homebrew
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
- * Using BtbN's GitHub releases as they cover both platforms:
197
- * https://github.com/BtbN/FFmpeg-Builds/releases
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
- * Checks: deps dir → system PATH → download.
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 archiveInfo = getFfmpegArchiveInfo(platform);
243
- return ensureBinary({
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
- downloadUrl: getFfmpegDownloadUrl(platform, arch),
457
+ targetName: `ffmpeg${ext}`,
247
458
  logger,
248
- ...archiveInfo
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
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-C5pB-QV1.mjs";
1
+ import { n as Fmp4BoxSplitter, o as buildFfmpegArgs, t as canonicalHash, u as isSoftwareDecode } from "./canonical-hash-CvL03d3i.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
- * Download ffmpeg static build for the current platform.
357
+ * The ffmpeg binary a node runs, and the static build we download when no image
358
+ * provided one.
168
359
  *
169
- * Sources:
170
- * - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
171
- * - macOS: https://evermeet.cx/ffmpeg/ or homebrew
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
- * Using BtbN's GitHub releases as they cover both platforms:
174
- * https://github.com/BtbN/FFmpeg-Builds/releases
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
- * Checks: deps dir → system PATH → download.
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 archiveInfo = getFfmpegArchiveInfo(platform);
220
- return ensureBinary({
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
- downloadUrl: getFfmpegDownloadUrl(platform, arch),
434
+ targetName: `ffmpeg${ext}`,
224
435
  logger,
225
- ...archiveInfo
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/types",
3
- "version": "1.2.223",
3
+ "version": "1.2.225",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",