@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.
- package/dist/{canonical-hash-C5pB-QV1.mjs → canonical-hash-CvL03d3i.mjs} +2 -1
- package/dist/{canonical-hash-BS9dQmcQ.js → canonical-hash-DYsYm8is.js} +2 -1
- 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 +33 -0
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/node.js +232 -13
- package/dist/node.mjs +233 -14
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
*
|
|
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
|
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-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
|
-
*
|
|
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
|