@camstack/types 1.2.227 → 1.2.228

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.
@@ -24,6 +24,18 @@ export interface DownloadOptions {
24
24
  readonly archiveFormat?: 'zip' | 'tar.gz' | 'tar.xz';
25
25
  /** Relative path within archive to the binary (e.g., 'ffmpeg-6.1/bin/ffmpeg') */
26
26
  readonly archiveInnerPath?: string;
27
+ /**
28
+ * SHA-256 the downloaded ARCHIVE must have, verified before a byte of it is
29
+ * extracted.
30
+ *
31
+ * Optional only because the python downloader predates it. For ffmpeg it is
32
+ * not optional in practice: the download is the ONLY source of the binary
33
+ * every transcode runs through, it arrives over a redirect to a third-party
34
+ * host, and the publisher ships no checksums of its own — so ours is the only
35
+ * thing standing between a changed artifact and a silent swap of the most
36
+ * privileged subprocess this system spawns.
37
+ */
38
+ readonly sha256?: string;
27
39
  }
28
40
  /**
29
41
  * Download a binary to the target directory.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The ffmpeg build every CamStack node downloads, per platform and arch.
3
+ *
4
+ * ## Why a download, on every node, including the container
5
+ *
6
+ * The version of ffmpeg used to be CODE: baked into the image, changeable only
7
+ * by rebuilding and rolling it. Now it is DATA — this file states the release,
8
+ * and a node that boots with a different one downloads it. Changing ffmpeg is a
9
+ * framework bump, not an image rebuild.
10
+ *
11
+ * It also settles the licence question by removing it. FFmpeg's licence is
12
+ * chosen at compile time: the core is LGPL 2.1+, `--enable-gpl` makes it GPL
13
+ * (for x264/x265), `--enable-version3` makes that v3, and `--enable-nonfree`
14
+ * makes the result undistributable. These are GPL v3 builds, and we do not
15
+ * distribute them: the user's own node fetches the archive from the project
16
+ * that publishes it, so we convey no GPL work and inherit none of the
17
+ * obligations that come with conveying one. That is the whole reason the image
18
+ * no longer installs ffmpeg at all — not a technical preference.
19
+ *
20
+ * ## Why jellyfin-ffmpeg
21
+ *
22
+ * One vendor covering linux-amd64, linux-arm64, darwin-arm64 and darwin-x64
23
+ * from one release, with the widest hardware surface of anything maintained:
24
+ * VAAPI, QSV, NVENC/NVDEC, AMF, Vulkan, OpenCL and DRM on amd64, plus Rockchip
25
+ * MPP on arm64. It reaches the vendor libraries through dlopen trampolines,
26
+ * so the binary is not linked against a driver stack it may not find.
27
+ *
28
+ * **What it still needs from the host, measured 2026-09-19.** On the Unraid
29
+ * HOST, which has no libva, `-vaapi_device` aborts the process outright —
30
+ * `implib-gen: libva-drm.so.2: failed to load library ... Assertion '0 &&
31
+ * "Assertion in generated code"' failed`. Inside our container, which installs
32
+ * libva plus the iHD driver, the same command encodes: verified end to end with
33
+ * `testsrc → format=nv12,hwupload → h264_vaapi`. So the image drops ffmpeg and
34
+ * KEEPS the Intel media stack; the drivers are the part a binary cannot bring.
35
+ *
36
+ * ## Why each artifact carries a checksum and a claim
37
+ *
38
+ * The download is now the only source, so `sha256` is verified before anything
39
+ * is extracted — jellyfin publishes no checksums on its release assets, so
40
+ * these are ours, taken at the version bump. And `expectedHwaccels` is what the
41
+ * build is supposed to be able to do: a binary that probes short of its own
42
+ * claim is a source that changed under us, which is precisely what went
43
+ * unnoticed for months when a static build turned out to carry no VAAPI at all
44
+ * (D536).
45
+ */
46
+ /** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
47
+ export declare const FFMPEG_RELEASE: "8.1.2-5";
48
+ /** ffmpeg 8.1.2 is what this release builds — for the log line, not resolution. */
49
+ export declare const FFMPEG_VERSION: "8.1.2";
50
+ export interface FfmpegArtifact {
51
+ readonly url: string;
52
+ readonly archiveFormat: 'zip' | 'tar.gz' | 'tar.xz';
53
+ /** SHA-256 of the ARCHIVE, verified before extraction. */
54
+ readonly sha256: string;
55
+ /** Bytes, as published — for the "downloading 60 MB" line. */
56
+ readonly sizeBytes: number;
57
+ /**
58
+ * `-hwaccels` names this build must report. Asserted after download: short of
59
+ * this is a changed source, not a quiet degradation.
60
+ */
61
+ readonly expectedHwaccels: readonly string[];
62
+ }
63
+ /** The artifact for this node, or a refusal naming what was asked for. */
64
+ export declare function getFfmpegArtifact(platform: string, arch: string): FfmpegArtifact;
65
+ /** The URL alone, for a caller that wants nothing else. */
66
+ export declare function getFfmpegDownloadUrl(platform: string, arch: string): string;
@@ -1,74 +1,72 @@
1
1
  /**
2
- * WHICH ffmpeg a node runs, and why it is never the host's.
2
+ * WHICH ffmpeg a node runs — one rule, on every node.
3
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:
4
+ * ## The rule
9
5
  *
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.
6
+ * 1. `CAMSTACK_FFMPEG_PATH`, when the operator set it and it exists. Pointing
7
+ * at your own build is a deliberate act and it wins over everything.
8
+ * 2. The pinned build this node has already downloaded, named by release
9
+ * (`ffmpeg-<release>`), in `<dataDir>/deps`.
10
+ * 3. Nothing — the caller downloads it. See `ffmpeg-artifacts.ts`.
13
11
  *
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:
12
+ * The system PATH is not on that list and must never be. A host binary is an
13
+ * unknown version with unknown vendor libraries, and it changes under us on any
14
+ * host update; a self-hosted NVR cannot assume the host has ffmpeg at all
15
+ * (D536).
16
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` |
17
+ * ## Why the container image is not special any more
21
18
  *
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.
19
+ * It was, for one day: the image installed ffmpeg, CamStack preferred it, and
20
+ * that made the answer depend on where a node ran — an image node got VAAPI, a
21
+ * native one got a portable build with none. Now every node downloads the same
22
+ * pinned build, so:
27
23
  *
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:
24
+ * - the ffmpeg version is DATA, not code: changing it is a framework bump, not
25
+ * an image rebuild and a container swap on every machine;
26
+ * - we ship no ffmpeg binary, so we convey no GPL work — the node fetches it
27
+ * from the project that publishes it;
28
+ * - one binary, one set of capabilities, everywhere. A bug that depends on
29
+ * which ffmpeg answered stops being possible.
32
30
  *
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
- * ```
31
+ * What the image still owes is the DRIVERS, which no binary can bring: libva
32
+ * plus the iHD media driver for Intel. Measured 2026-09-19 — the portable build
33
+ * encodes VAAPI inside our container and ABORTS on the bare Unraid host, where
34
+ * `libva-drm.so.2` does not exist.
37
35
  *
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.
36
+ * ## Absent is absent
41
37
  *
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.
38
+ * The naming carries the release, so two versions coexist during a change and a
39
+ * rollback is a file that is already on disk rather than a download.
45
40
  */
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
41
  /** Operator override. Unset on every node unless a human deliberately set it. */
49
42
  export declare const FFMPEG_PATH_ENV = "CAMSTACK_FFMPEG_PATH";
50
- export type FfmpegBinaryOrigin = 'override' | 'bundled' | 'downloaded';
43
+ export type FfmpegBinaryOrigin = 'override' | 'downloaded';
51
44
  export interface FfmpegBinaryChoice {
52
45
  readonly origin: FfmpegBinaryOrigin;
53
46
  readonly path: string;
54
47
  }
55
48
  export interface FfmpegBinarySourceInput {
56
49
  readonly platform: string;
57
- /** `<dataDir>/deps/ffmpeg`, where a downloaded copy lands. */
58
- readonly downloadedPath: string;
50
+ /** `<dataDir>/deps/ffmpeg-<release>` — where the pinned build lands. */
51
+ readonly pinnedPath: string;
59
52
  /** The raw value of {@link FFMPEG_PATH_ENV}, or null/empty when unset. */
60
53
  readonly override: string | null | undefined;
61
54
  readonly exists: (path: string) => boolean;
62
55
  }
63
56
  /**
64
- * The binary to use, or `null` when there is nothing yet and one must be
65
- * downloaded.
57
+ * The file name the pinned build is stored under.
58
+ *
59
+ * Versioned on purpose: a download is then idempotent, two releases can sit
60
+ * side by side while a change rolls through a cluster, and going back is a path
61
+ * that already exists instead of a fetch that has to succeed.
62
+ */
63
+ export declare function ffmpegBinaryName(platform: string): string;
64
+ /**
65
+ * The binary to use, or `null` when it has to be downloaded first.
66
66
  *
67
67
  * An override that does not exist is NOT silently skipped — it is an operator
68
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.
69
+ * would hide it. The caller reports it and carries on with the pinned build,
70
+ * which is the only safe direction: a typo must not stop a node serving video.
71
71
  */
72
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;
@@ -1,47 +1,18 @@
1
1
  import type { IScopedLogger } from '../interfaces/logging.js';
2
2
  /**
3
- * One artifact per platform+arch: the URL, how it is packed, and what the
4
- * binary inside is SUPPOSED to be able to do.
3
+ * The ffmpeg this node runs, downloading the pinned build if it is not here
4
+ * yet, and reporting what it got.
5
5
  *
6
- * It is a table rather than two functions computing strings because the three
7
- * facts belong together and drifted apart when they did not:
6
+ * Order: the operator's `CAMSTACK_FFMPEG_PATH` → the pinned build already on
7
+ * disk → download it. No PATH, no image copy, no per-platform special case —
8
+ * see `ffmpeg-binary-source.ts` for why the image stopped being special after
9
+ * exactly one day.
8
10
  *
9
- * - the darwin branch computed an arch and then discarded it, handing an
10
- * Intel Mac an arm64 Mach-O it cannot execute under any translation;
11
- * - a single `FFMPEG_VERSION = '7.1'` named the version of nothing — the linux
12
- * URL is unversioned and serves 7.0.2 — so the inner path built from it never
13
- * matched and only the extractor's recursive fallback saved it;
14
- * - and nothing recorded what a download was expected to be capable of, which
15
- * is how a build with no VAAPI at all served every transcode on this fleet
16
- * for months (D536).
17
- *
18
- * `expectedHwaccels` is that missing claim. It is deliberately EMPTY for the
19
- * linux static builds: they carry no hardware support and pretending otherwise
20
- * would make the assertion a lie. That emptiness is the honest description of
21
- * a portable build, and the reason a node that has an image prefers the image's
22
- * ffmpeg over this one.
23
- */
24
- export interface FfmpegArtifact {
25
- readonly url: string;
26
- readonly archiveFormat: 'zip' | 'tar.gz' | 'tar.xz';
27
- /** `-hwaccels` names this artifact is built with. Empty = none, honestly. */
28
- readonly expectedHwaccels: readonly string[];
29
- }
30
- /** The artifact for this node, or a refusal naming what was asked for. */
31
- export declare function getFfmpegArtifact(platform: string, arch: string): FfmpegArtifact;
32
- /** The URL alone, for a caller that wants nothing else. */
33
- export declare function getFfmpegDownloadUrl(platform: string, arch: string): string;
34
- /**
35
- * Ensure an ffmpeg binary WE provide is available on this node, and report
36
- * which one and what it can do.
37
- *
38
- * Order: the operator's voluntary override → the one our image installed → a
39
- * copy we already downloaded → download it. The system PATH is not in that
40
- * list and must never be: see `ffmpeg-binary-source.ts`.
41
- *
42
- * The line it logs is the point as much as the path is. "Which ffmpeg am I
43
- * running, and does it have vaapi" had no answer anywhere in the product, and
44
- * that is what let a hardware-incapable binary serve every transcode on a hub
45
- * with a working iGPU for months, silently in software.
11
+ * The line it logs is half the point. "Which ffmpeg am I running, and does it
12
+ * have vaapi" had no answer anywhere in the product, and that is what let a
13
+ * hardware-incapable binary serve every transcode on a hub with a working iGPU
14
+ * for months. The other half is the assertion beneath it: a build that probes
15
+ * short of what its artifact claims is a source that changed under us, and it
16
+ * says so with both lists.
46
17
  */
47
18
  export declare function ensureFfmpeg(dataDir: string, logger: IScopedLogger): Promise<string>;
@@ -1,3 +1,4 @@
1
1
  export { ensureBinary, downloadBinary, findInPath, getPlatformInfo, buildBinaryPath, } from './binary-downloader.js';
2
- export { ensureFfmpeg, getFfmpegArtifact, getFfmpegDownloadUrl } from './ffmpeg-downloader.js';
2
+ export { ensureFfmpeg } from './ffmpeg-downloader.js';
3
+ export { FFMPEG_RELEASE, getFfmpegArtifact, getFfmpegDownloadUrl } from './ffmpeg-artifacts.js';
3
4
  export { ensurePython, installPythonPackages, installPythonRequirements, getPythonDownloadUrl, PYTHON_VERSION, } from './python-downloader.js';
package/dist/node.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { PlatformInfo } from './deps/binary-downloader.js';
2
2
  export { buildBinaryPath, downloadBinary, ensureBinary, findInPath, getPlatformInfo, } from './deps/binary-downloader.js';
3
- export { ensureFfmpeg, getFfmpegArtifact, getFfmpegDownloadUrl } from './deps/ffmpeg-downloader.js';
3
+ export { ensureFfmpeg } from './deps/ffmpeg-downloader.js';
4
+ export { FFMPEG_RELEASE, getFfmpegArtifact, getFfmpegDownloadUrl } from './deps/ffmpeg-artifacts.js';
4
5
  export { probeFfmpegBinary, parseFfmpegCapabilities, type FfmpegBinaryCapabilities, } from './ffmpeg/binary-capabilities.js';
5
6
  export { ensurePython, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, PYTHON_VERSION, } from './deps/python-downloader.js';
6
7
  export type { Fmp4FragmentChildArgs, Fmp4FragmentChildDeps, } from './ffmpeg/fmp4-fragment-child.js';
package/dist/node.js CHANGED
@@ -73,6 +73,23 @@ function findInPath(name) {
73
73
  }
74
74
  }
75
75
  /**
76
+ * Refuse an artifact that is not the one we pinned, and leave nothing behind.
77
+ *
78
+ * The failure is deliberately loud and terminal rather than a warning: a
79
+ * mismatch means the bytes are not what was verified, and "not what was
80
+ * verified" is the only state in which running this binary is worse than not
81
+ * having it. The partial file is removed so a retry cannot pick it up.
82
+ */
83
+ function verifyChecksum(file, expected, name, url) {
84
+ if (expected === void 0) return;
85
+ const actual = (0, node_crypto.createHash)("sha256").update((0, node_fs.readFileSync)(file)).digest("hex");
86
+ if (actual === expected) return;
87
+ try {
88
+ (0, node_fs.unlinkSync)(file);
89
+ } catch {}
90
+ throw new Error(`${name}: downloaded artifact does not match its pinned checksum — refusing it. url=${url} expected=${expected} actual=${actual}`);
91
+ }
92
+ /**
76
93
  * Download a binary to the target directory.
77
94
  * Handles archives (zip, tar.gz, tar.xz) and raw binaries.
78
95
  */
@@ -97,6 +114,7 @@ async function downloadBinary(opts) {
97
114
  const ext = archiveFormat ?? "tar.gz";
98
115
  const tmpArchive = (0, node_path.join)(targetDir, `${name}-download.${ext}`);
99
116
  await (0, node_stream_promises.pipeline)(node_stream.Readable.fromWeb(response.body), (0, node_fs.createWriteStream)(tmpArchive));
117
+ verifyChecksum(tmpArchive, opts.sha256, name, url);
100
118
  const tmpExtractDir = (0, node_path.join)(targetDir, `${name}-extract`);
101
119
  (0, node_fs.mkdirSync)(tmpExtractDir, { recursive: true });
102
120
  if (ext === "zip") try {
@@ -143,7 +161,10 @@ async function downloadBinary(opts) {
143
161
  recursive: true,
144
162
  force: true
145
163
  });
146
- } else await (0, node_stream_promises.pipeline)(node_stream.Readable.fromWeb(response.body), (0, node_fs.createWriteStream)(targetPath));
164
+ } else {
165
+ await (0, node_stream_promises.pipeline)(node_stream.Readable.fromWeb(response.body), (0, node_fs.createWriteStream)(targetPath));
166
+ verifyChecksum(targetPath, opts.sha256, name, url);
167
+ }
147
168
  (0, node_fs.chmodSync)(targetPath, 493);
148
169
  logger.info("Binary downloaded", { meta: {
149
170
  name,
@@ -294,64 +315,177 @@ async function probeFfmpegBinary(path) {
294
315
  return parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut);
295
316
  }
296
317
  //#endregion
318
+ //#region src/deps/ffmpeg-artifacts.ts
319
+ /**
320
+ * The ffmpeg build every CamStack node downloads, per platform and arch.
321
+ *
322
+ * ## Why a download, on every node, including the container
323
+ *
324
+ * The version of ffmpeg used to be CODE: baked into the image, changeable only
325
+ * by rebuilding and rolling it. Now it is DATA — this file states the release,
326
+ * and a node that boots with a different one downloads it. Changing ffmpeg is a
327
+ * framework bump, not an image rebuild.
328
+ *
329
+ * It also settles the licence question by removing it. FFmpeg's licence is
330
+ * chosen at compile time: the core is LGPL 2.1+, `--enable-gpl` makes it GPL
331
+ * (for x264/x265), `--enable-version3` makes that v3, and `--enable-nonfree`
332
+ * makes the result undistributable. These are GPL v3 builds, and we do not
333
+ * distribute them: the user's own node fetches the archive from the project
334
+ * that publishes it, so we convey no GPL work and inherit none of the
335
+ * obligations that come with conveying one. That is the whole reason the image
336
+ * no longer installs ffmpeg at all — not a technical preference.
337
+ *
338
+ * ## Why jellyfin-ffmpeg
339
+ *
340
+ * One vendor covering linux-amd64, linux-arm64, darwin-arm64 and darwin-x64
341
+ * from one release, with the widest hardware surface of anything maintained:
342
+ * VAAPI, QSV, NVENC/NVDEC, AMF, Vulkan, OpenCL and DRM on amd64, plus Rockchip
343
+ * MPP on arm64. It reaches the vendor libraries through dlopen trampolines,
344
+ * so the binary is not linked against a driver stack it may not find.
345
+ *
346
+ * **What it still needs from the host, measured 2026-09-19.** On the Unraid
347
+ * HOST, which has no libva, `-vaapi_device` aborts the process outright —
348
+ * `implib-gen: libva-drm.so.2: failed to load library ... Assertion '0 &&
349
+ * "Assertion in generated code"' failed`. Inside our container, which installs
350
+ * libva plus the iHD driver, the same command encodes: verified end to end with
351
+ * `testsrc → format=nv12,hwupload → h264_vaapi`. So the image drops ffmpeg and
352
+ * KEEPS the Intel media stack; the drivers are the part a binary cannot bring.
353
+ *
354
+ * ## Why each artifact carries a checksum and a claim
355
+ *
356
+ * The download is now the only source, so `sha256` is verified before anything
357
+ * is extracted — jellyfin publishes no checksums on its release assets, so
358
+ * these are ours, taken at the version bump. And `expectedHwaccels` is what the
359
+ * build is supposed to be able to do: a binary that probes short of its own
360
+ * claim is a source that changed under us, which is precisely what went
361
+ * unnoticed for months when a static build turned out to carry no VAAPI at all
362
+ * (D536).
363
+ */
364
+ /** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
365
+ var FFMPEG_RELEASE = "8.1.2-5";
366
+ var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
367
+ function portable(target) {
368
+ return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
369
+ }
370
+ /**
371
+ * Every artifact below was fetched and hashed on 2026-09-19, and each one's
372
+ * capability claim was verified by RUNNING it: the linux64 build on the hub
373
+ * itself, the two macOS builds on an M-series Mac (the x86_64 one under
374
+ * Rosetta, which translates x86 to arm — never the reverse, which is why the
375
+ * darwin split matters at all).
376
+ *
377
+ * linux-arm64 is hashed but NOT executed — there is no arm64 Linux node in
378
+ * this cluster yet. Its claim is taken from the project's build script, so the
379
+ * first ARM node to boot will either confirm it or trip the assertion, which is
380
+ * the outcome the assertion exists for.
381
+ */
382
+ var FFMPEG_ARTIFACTS = {
383
+ "linux-x64": {
384
+ url: portable("linux64"),
385
+ archiveFormat: "tar.xz",
386
+ sha256: "1fd859927053c44a4f2dbf67ae8b9ba8d29fb3b8930df0dd57d91aa60589363d",
387
+ sizeBytes: 60255400,
388
+ expectedHwaccels: [
389
+ "vaapi",
390
+ "qsv",
391
+ "drm",
392
+ "vulkan",
393
+ "opencl"
394
+ ]
395
+ },
396
+ "linux-arm64": {
397
+ url: portable("linuxarm64"),
398
+ archiveFormat: "tar.xz",
399
+ sha256: "1bd4fafaf4c309cad896d3479152c31d6b7604d4be8b8526882ea16cc6d5f589",
400
+ sizeBytes: 53958064,
401
+ expectedHwaccels: ["vaapi", "drm"]
402
+ },
403
+ "darwin-arm64": {
404
+ url: portable("macarm64"),
405
+ archiveFormat: "tar.xz",
406
+ sha256: "b2ac80bb184e9a2f3f7c236876b2f56a5639596a95b42f41ced34fab66ad720d",
407
+ sizeBytes: 32896684,
408
+ expectedHwaccels: ["videotoolbox"]
409
+ },
410
+ "darwin-x64": {
411
+ url: portable("mac64"),
412
+ archiveFormat: "tar.xz",
413
+ sha256: "021cd321ea169722cd2e4aca35884305449666a0d231d3378af7f87d6a5626e5",
414
+ sizeBytes: 37852180,
415
+ expectedHwaccels: ["videotoolbox"]
416
+ }
417
+ };
418
+ /** The artifact for this node, or a refusal naming what was asked for. */
419
+ function getFfmpegArtifact(platform, arch) {
420
+ const artifact = FFMPEG_ARTIFACTS[`${platform}-${arch}`];
421
+ if (!artifact) throw new Error(`Unsupported platform/architecture for ffmpeg: ${platform}-${arch}. Point CAMSTACK_FFMPEG_PATH at a binary this node can run.`);
422
+ return artifact;
423
+ }
424
+ /** The URL alone, for a caller that wants nothing else. */
425
+ function getFfmpegDownloadUrl(platform, arch) {
426
+ return getFfmpegArtifact(platform, arch).url;
427
+ }
428
+ //#endregion
297
429
  //#region src/deps/ffmpeg-binary-source.ts
298
430
  /**
299
- * WHICH ffmpeg a node runs, and why it is never the host's.
431
+ * WHICH ffmpeg a node runs — one rule, on every node.
300
432
  *
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:
433
+ * ## The rule
306
434
  *
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.
435
+ * 1. `CAMSTACK_FFMPEG_PATH`, when the operator set it and it exists. Pointing
436
+ * at your own build is a deliberate act and it wins over everything.
437
+ * 2. The pinned build this node has already downloaded, named by release
438
+ * (`ffmpeg-<release>`), in `<dataDir>/deps`.
439
+ * 3. Nothing — the caller downloads it. See `ffmpeg-artifacts.ts`.
310
440
  *
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:
441
+ * The system PATH is not on that list and must never be. A host binary is an
442
+ * unknown version with unknown vendor libraries, and it changes under us on any
443
+ * host update; a self-hosted NVR cannot assume the host has ffmpeg at all
444
+ * (D536).
313
445
  *
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` |
446
+ * ## Why the container image is not special any more
318
447
  *
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.
448
+ * It was, for one day: the image installed ffmpeg, CamStack preferred it, and
449
+ * that made the answer depend on where a node ran — an image node got VAAPI, a
450
+ * native one got a portable build with none. Now every node downloads the same
451
+ * pinned build, so:
324
452
  *
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:
453
+ * - the ffmpeg version is DATA, not code: changing it is a framework bump, not
454
+ * an image rebuild and a container swap on every machine;
455
+ * - we ship no ffmpeg binary, so we convey no GPL work — the node fetches it
456
+ * from the project that publishes it;
457
+ * - one binary, one set of capabilities, everywhere. A bug that depends on
458
+ * which ffmpeg answered stops being possible.
329
459
  *
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
- * ```
460
+ * What the image still owes is the DRIVERS, which no binary can bring: libva
461
+ * plus the iHD media driver for Intel. Measured 2026-09-19 — the portable build
462
+ * encodes VAAPI inside our container and ABORTS on the bare Unraid host, where
463
+ * `libva-drm.so.2` does not exist.
334
464
  *
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.
465
+ * ## Absent is absent
338
466
  *
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.
467
+ * The naming carries the release, so two versions coexist during a change and a
468
+ * rollback is a file that is already on disk rather than a download.
342
469
  */
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
470
  /** Operator override. Unset on every node unless a human deliberately set it. */
346
471
  var FFMPEG_PATH_ENV = "CAMSTACK_FFMPEG_PATH";
347
472
  /**
348
- * The binary to use, or `null` when there is nothing yet and one must be
349
- * downloaded.
473
+ * The file name the pinned build is stored under.
474
+ *
475
+ * Versioned on purpose: a download is then idempotent, two releases can sit
476
+ * side by side while a change rolls through a cluster, and going back is a path
477
+ * that already exists instead of a fetch that has to succeed.
478
+ */
479
+ function ffmpegBinaryName(platform) {
480
+ return `ffmpeg-${FFMPEG_RELEASE}${platform === "win32" ? ".exe" : ""}`;
481
+ }
482
+ /**
483
+ * The binary to use, or `null` when it has to be downloaded first.
350
484
  *
351
485
  * An override that does not exist is NOT silently skipped — it is an operator
352
486
  * 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.
487
+ * would hide it. The caller reports it and carries on with the pinned build,
488
+ * which is the only safe direction: a typo must not stop a node serving video.
355
489
  */
356
490
  function chooseFfmpegBinary(input) {
357
491
  const override = input.override?.trim() ?? "";
@@ -359,121 +493,83 @@ function chooseFfmpegBinary(input) {
359
493
  origin: "override",
360
494
  path: override
361
495
  };
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 {
496
+ if (input.exists(input.pinnedPath)) return {
368
497
  origin: "downloaded",
369
- path: input.downloadedPath
498
+ path: input.pinnedPath
370
499
  };
371
500
  return null;
372
501
  }
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
502
  //#endregion
378
503
  //#region src/deps/ffmpeg-downloader.ts
379
504
  /**
380
- * The ffmpeg binary a node runs, and the static build we download when no image
381
- * provided one.
382
- *
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.
505
+ * Provisioning the ffmpeg binary for this node.
386
506
  *
387
- * Sources for the download:
388
- * - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
389
- * - macOS: https://www.osxexperts.net/
507
+ * WHICH build, and why every node downloads the same one instead of using
508
+ * anything the machine already has, is in `ffmpeg-binary-source.ts` and
509
+ * `ffmpeg-artifacts.ts`. This module is the act: resolve, download if needed,
510
+ * probe, and say what it got.
390
511
  */
391
512
  /**
392
- * johnvansickle for linux (unversioned "release" URLs, so no version appears
393
- * here), osxexperts for macOS — two different builds, and the Intel one is a
394
- * different file at a different version from the arm one.
395
- */
396
- var FFMPEG_ARTIFACTS = {
397
- "linux-x64": {
398
- url: "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz",
399
- archiveFormat: "tar.xz",
400
- expectedHwaccels: []
401
- },
402
- "linux-arm64": {
403
- url: "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz",
404
- archiveFormat: "tar.xz",
405
- expectedHwaccels: []
406
- },
407
- "darwin-arm64": {
408
- url: "https://www.osxexperts.net/ffmpeg71arm.zip",
409
- archiveFormat: "zip",
410
- expectedHwaccels: ["videotoolbox"]
411
- },
412
- "darwin-x64": {
413
- url: "https://www.osxexperts.net/ffmpeg80intel.zip",
414
- archiveFormat: "zip",
415
- expectedHwaccels: ["videotoolbox"]
416
- }
417
- };
418
- /** The artifact for this node, or a refusal naming what was asked for. */
419
- function getFfmpegArtifact(platform, arch) {
420
- const artifact = FFMPEG_ARTIFACTS[`${platform}-${arch}`];
421
- if (!artifact) throw new Error(`Unsupported platform/architecture for ffmpeg: ${platform}-${arch}`);
422
- return artifact;
423
- }
424
- /** The URL alone, for a caller that wants nothing else. */
425
- function getFfmpegDownloadUrl(platform, arch) {
426
- return getFfmpegArtifact(platform, arch).url;
427
- }
428
- /**
429
- * Ensure an ffmpeg binary WE provide is available on this node, and report
430
- * which one and what it can do.
431
- *
432
- * Order: the operator's voluntary override → the one our image installed → a
433
- * copy we already downloaded → download it. The system PATH is not in that
434
- * list and must never be: see `ffmpeg-binary-source.ts`.
435
- *
436
- * The line it logs is the point as much as the path is. "Which ffmpeg am I
437
- * running, and does it have vaapi" had no answer anywhere in the product, and
438
- * that is what let a hardware-incapable binary serve every transcode on a hub
439
- * with a working iGPU for months, silently in software.
513
+ * The ffmpeg this node runs, downloading the pinned build if it is not here
514
+ * yet, and reporting what it got.
515
+ *
516
+ * Order: the operator's `CAMSTACK_FFMPEG_PATH` → the pinned build already on
517
+ * disk → download it. No PATH, no image copy, no per-platform special case —
518
+ * see `ffmpeg-binary-source.ts` for why the image stopped being special after
519
+ * exactly one day.
520
+ *
521
+ * The line it logs is half the point. "Which ffmpeg am I running, and does it
522
+ * have vaapi" had no answer anywhere in the product, and that is what let a
523
+ * hardware-incapable binary serve every transcode on a hub with a working iGPU
524
+ * for months. The other half is the assertion beneath it: a build that probes
525
+ * short of what its artifact claims is a source that changed under us, and it
526
+ * says so with both lists.
440
527
  */
441
528
  async function ensureFfmpeg(dataDir, logger) {
442
529
  const depsDir = (0, node_path.join)(dataDir, "deps");
443
530
  const platform = process.platform;
444
531
  const arch = process.arch;
445
- const ext = platform === "win32" ? ".exe" : "";
446
- const downloadedPath = (0, node_path.join)(depsDir, `ffmpeg${ext}`);
532
+ const pinnedPath = (0, node_path.join)(depsDir, ffmpegBinaryName(platform));
447
533
  const override = process.env[FFMPEG_PATH_ENV];
448
534
  const chosen = chooseFfmpegBinary({
449
535
  platform,
450
- downloadedPath,
536
+ pinnedPath,
451
537
  override,
452
538
  exists: node_fs.existsSync
453
539
  });
454
540
  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 } });
455
541
  const artifact = chosen === null ? getFfmpegArtifact(platform, arch) : null;
542
+ if (artifact !== null) logger.info("ffmpeg is not on this node yet — downloading the pinned build", { meta: {
543
+ release: FFMPEG_RELEASE,
544
+ url: artifact.url,
545
+ sizeMb: Math.round(artifact.sizeBytes / 1e6)
546
+ } });
456
547
  const path = chosen?.path ?? await downloadBinary({
457
548
  name: "ffmpeg",
458
549
  url: artifact?.url ?? getFfmpegDownloadUrl(platform, arch),
459
550
  targetDir: depsDir,
460
- targetName: `ffmpeg${ext}`,
551
+ targetName: ffmpegBinaryName(platform),
461
552
  logger,
462
553
  isArchive: true,
463
- archiveFormat: artifact?.archiveFormat ?? "tar.xz"
554
+ archiveFormat: artifact?.archiveFormat ?? "tar.xz",
555
+ archiveInnerPath: "ffmpeg",
556
+ ...artifact === null ? {} : { sha256: artifact.sha256 }
464
557
  });
465
558
  const capabilities = await probeFfmpegBinary(path);
466
559
  logger.info("ffmpeg binary resolved", { meta: {
467
560
  path,
468
561
  origin: chosen?.origin ?? "downloaded",
562
+ release: FFMPEG_RELEASE,
469
563
  version: capabilities.version,
470
564
  hwaccels: [...capabilities.hwaccels].join(",") || "none"
471
565
  } });
472
- if (artifact !== null) {
473
- const missing = artifact.expectedHwaccels.filter((name) => !capabilities.hwaccels.has(name));
474
- if (missing.length > 0) logger.error("the ffmpeg we downloaded cannot do what this artifact is supposed to — the source changed under us", { meta: {
475
- url: artifact.url,
476
- expected: artifact.expectedHwaccels.join(","),
566
+ if (chosen?.origin !== "override") {
567
+ const expected = getFfmpegArtifact(platform, arch).expectedHwaccels;
568
+ const missing = expected.filter((name) => !capabilities.hwaccels.has(name));
569
+ if (missing.length > 0) logger.error("this ffmpeg cannot do what its artifact is supposed to — the source changed under us", { meta: {
570
+ path,
571
+ release: FFMPEG_RELEASE,
572
+ expected: expected.join(","),
477
573
  missing: missing.join(","),
478
574
  got: [...capabilities.hwaccels].join(",") || "none"
479
575
  } });
@@ -1961,6 +2057,7 @@ function resolveExportFingerprint(input) {
1961
2057
  }
1962
2058
  //#endregion
1963
2059
  exports.ChildCostRegistry = ChildCostRegistry;
2060
+ exports.FFMPEG_RELEASE = FFMPEG_RELEASE;
1964
2061
  exports.FfmpegProcess = FfmpegProcess;
1965
2062
  exports.FilesystemStorageProvider = FilesystemStorageProvider;
1966
2063
  exports.Fmp4FragmentChild = Fmp4FragmentChild;
package/dist/node.mjs CHANGED
@@ -50,6 +50,23 @@ function findInPath(name) {
50
50
  }
51
51
  }
52
52
  /**
53
+ * Refuse an artifact that is not the one we pinned, and leave nothing behind.
54
+ *
55
+ * The failure is deliberately loud and terminal rather than a warning: a
56
+ * mismatch means the bytes are not what was verified, and "not what was
57
+ * verified" is the only state in which running this binary is worse than not
58
+ * having it. The partial file is removed so a retry cannot pick it up.
59
+ */
60
+ function verifyChecksum(file, expected, name, url) {
61
+ if (expected === void 0) return;
62
+ const actual = createHash("sha256").update(readFileSync(file)).digest("hex");
63
+ if (actual === expected) return;
64
+ try {
65
+ unlinkSync(file);
66
+ } catch {}
67
+ throw new Error(`${name}: downloaded artifact does not match its pinned checksum — refusing it. url=${url} expected=${expected} actual=${actual}`);
68
+ }
69
+ /**
53
70
  * Download a binary to the target directory.
54
71
  * Handles archives (zip, tar.gz, tar.xz) and raw binaries.
55
72
  */
@@ -74,6 +91,7 @@ async function downloadBinary(opts) {
74
91
  const ext = archiveFormat ?? "tar.gz";
75
92
  const tmpArchive = join(targetDir, `${name}-download.${ext}`);
76
93
  await pipeline(Readable.fromWeb(response.body), createWriteStream(tmpArchive));
94
+ verifyChecksum(tmpArchive, opts.sha256, name, url);
77
95
  const tmpExtractDir = join(targetDir, `${name}-extract`);
78
96
  mkdirSync(tmpExtractDir, { recursive: true });
79
97
  if (ext === "zip") try {
@@ -120,7 +138,10 @@ async function downloadBinary(opts) {
120
138
  recursive: true,
121
139
  force: true
122
140
  });
123
- } else await pipeline(Readable.fromWeb(response.body), createWriteStream(targetPath));
141
+ } else {
142
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(targetPath));
143
+ verifyChecksum(targetPath, opts.sha256, name, url);
144
+ }
124
145
  chmodSync(targetPath, 493);
125
146
  logger.info("Binary downloaded", { meta: {
126
147
  name,
@@ -271,64 +292,177 @@ async function probeFfmpegBinary(path) {
271
292
  return parseFfmpegCapabilities(path, versionOut, hwaccelOut, encoderOut);
272
293
  }
273
294
  //#endregion
295
+ //#region src/deps/ffmpeg-artifacts.ts
296
+ /**
297
+ * The ffmpeg build every CamStack node downloads, per platform and arch.
298
+ *
299
+ * ## Why a download, on every node, including the container
300
+ *
301
+ * The version of ffmpeg used to be CODE: baked into the image, changeable only
302
+ * by rebuilding and rolling it. Now it is DATA — this file states the release,
303
+ * and a node that boots with a different one downloads it. Changing ffmpeg is a
304
+ * framework bump, not an image rebuild.
305
+ *
306
+ * It also settles the licence question by removing it. FFmpeg's licence is
307
+ * chosen at compile time: the core is LGPL 2.1+, `--enable-gpl` makes it GPL
308
+ * (for x264/x265), `--enable-version3` makes that v3, and `--enable-nonfree`
309
+ * makes the result undistributable. These are GPL v3 builds, and we do not
310
+ * distribute them: the user's own node fetches the archive from the project
311
+ * that publishes it, so we convey no GPL work and inherit none of the
312
+ * obligations that come with conveying one. That is the whole reason the image
313
+ * no longer installs ffmpeg at all — not a technical preference.
314
+ *
315
+ * ## Why jellyfin-ffmpeg
316
+ *
317
+ * One vendor covering linux-amd64, linux-arm64, darwin-arm64 and darwin-x64
318
+ * from one release, with the widest hardware surface of anything maintained:
319
+ * VAAPI, QSV, NVENC/NVDEC, AMF, Vulkan, OpenCL and DRM on amd64, plus Rockchip
320
+ * MPP on arm64. It reaches the vendor libraries through dlopen trampolines,
321
+ * so the binary is not linked against a driver stack it may not find.
322
+ *
323
+ * **What it still needs from the host, measured 2026-09-19.** On the Unraid
324
+ * HOST, which has no libva, `-vaapi_device` aborts the process outright —
325
+ * `implib-gen: libva-drm.so.2: failed to load library ... Assertion '0 &&
326
+ * "Assertion in generated code"' failed`. Inside our container, which installs
327
+ * libva plus the iHD driver, the same command encodes: verified end to end with
328
+ * `testsrc → format=nv12,hwupload → h264_vaapi`. So the image drops ffmpeg and
329
+ * KEEPS the Intel media stack; the drivers are the part a binary cannot bring.
330
+ *
331
+ * ## Why each artifact carries a checksum and a claim
332
+ *
333
+ * The download is now the only source, so `sha256` is verified before anything
334
+ * is extracted — jellyfin publishes no checksums on its release assets, so
335
+ * these are ours, taken at the version bump. And `expectedHwaccels` is what the
336
+ * build is supposed to be able to do: a binary that probes short of its own
337
+ * claim is a source that changed under us, which is precisely what went
338
+ * unnoticed for months when a static build turned out to carry no VAAPI at all
339
+ * (D536).
340
+ */
341
+ /** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
342
+ var FFMPEG_RELEASE = "8.1.2-5";
343
+ var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
344
+ function portable(target) {
345
+ return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
346
+ }
347
+ /**
348
+ * Every artifact below was fetched and hashed on 2026-09-19, and each one's
349
+ * capability claim was verified by RUNNING it: the linux64 build on the hub
350
+ * itself, the two macOS builds on an M-series Mac (the x86_64 one under
351
+ * Rosetta, which translates x86 to arm — never the reverse, which is why the
352
+ * darwin split matters at all).
353
+ *
354
+ * linux-arm64 is hashed but NOT executed — there is no arm64 Linux node in
355
+ * this cluster yet. Its claim is taken from the project's build script, so the
356
+ * first ARM node to boot will either confirm it or trip the assertion, which is
357
+ * the outcome the assertion exists for.
358
+ */
359
+ var FFMPEG_ARTIFACTS = {
360
+ "linux-x64": {
361
+ url: portable("linux64"),
362
+ archiveFormat: "tar.xz",
363
+ sha256: "1fd859927053c44a4f2dbf67ae8b9ba8d29fb3b8930df0dd57d91aa60589363d",
364
+ sizeBytes: 60255400,
365
+ expectedHwaccels: [
366
+ "vaapi",
367
+ "qsv",
368
+ "drm",
369
+ "vulkan",
370
+ "opencl"
371
+ ]
372
+ },
373
+ "linux-arm64": {
374
+ url: portable("linuxarm64"),
375
+ archiveFormat: "tar.xz",
376
+ sha256: "1bd4fafaf4c309cad896d3479152c31d6b7604d4be8b8526882ea16cc6d5f589",
377
+ sizeBytes: 53958064,
378
+ expectedHwaccels: ["vaapi", "drm"]
379
+ },
380
+ "darwin-arm64": {
381
+ url: portable("macarm64"),
382
+ archiveFormat: "tar.xz",
383
+ sha256: "b2ac80bb184e9a2f3f7c236876b2f56a5639596a95b42f41ced34fab66ad720d",
384
+ sizeBytes: 32896684,
385
+ expectedHwaccels: ["videotoolbox"]
386
+ },
387
+ "darwin-x64": {
388
+ url: portable("mac64"),
389
+ archiveFormat: "tar.xz",
390
+ sha256: "021cd321ea169722cd2e4aca35884305449666a0d231d3378af7f87d6a5626e5",
391
+ sizeBytes: 37852180,
392
+ expectedHwaccels: ["videotoolbox"]
393
+ }
394
+ };
395
+ /** The artifact for this node, or a refusal naming what was asked for. */
396
+ function getFfmpegArtifact(platform, arch) {
397
+ const artifact = FFMPEG_ARTIFACTS[`${platform}-${arch}`];
398
+ if (!artifact) throw new Error(`Unsupported platform/architecture for ffmpeg: ${platform}-${arch}. Point CAMSTACK_FFMPEG_PATH at a binary this node can run.`);
399
+ return artifact;
400
+ }
401
+ /** The URL alone, for a caller that wants nothing else. */
402
+ function getFfmpegDownloadUrl(platform, arch) {
403
+ return getFfmpegArtifact(platform, arch).url;
404
+ }
405
+ //#endregion
274
406
  //#region src/deps/ffmpeg-binary-source.ts
275
407
  /**
276
- * WHICH ffmpeg a node runs, and why it is never the host's.
408
+ * WHICH ffmpeg a node runs — one rule, on every node.
277
409
  *
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:
410
+ * ## The rule
283
411
  *
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.
412
+ * 1. `CAMSTACK_FFMPEG_PATH`, when the operator set it and it exists. Pointing
413
+ * at your own build is a deliberate act and it wins over everything.
414
+ * 2. The pinned build this node has already downloaded, named by release
415
+ * (`ffmpeg-<release>`), in `<dataDir>/deps`.
416
+ * 3. Nothing — the caller downloads it. See `ffmpeg-artifacts.ts`.
287
417
  *
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:
418
+ * The system PATH is not on that list and must never be. A host binary is an
419
+ * unknown version with unknown vendor libraries, and it changes under us on any
420
+ * host update; a self-hosted NVR cannot assume the host has ffmpeg at all
421
+ * (D536).
290
422
  *
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` |
423
+ * ## Why the container image is not special any more
295
424
  *
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.
425
+ * It was, for one day: the image installed ffmpeg, CamStack preferred it, and
426
+ * that made the answer depend on where a node ran — an image node got VAAPI, a
427
+ * native one got a portable build with none. Now every node downloads the same
428
+ * pinned build, so:
301
429
  *
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:
430
+ * - the ffmpeg version is DATA, not code: changing it is a framework bump, not
431
+ * an image rebuild and a container swap on every machine;
432
+ * - we ship no ffmpeg binary, so we convey no GPL work — the node fetches it
433
+ * from the project that publishes it;
434
+ * - one binary, one set of capabilities, everywhere. A bug that depends on
435
+ * which ffmpeg answered stops being possible.
306
436
  *
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
- * ```
437
+ * What the image still owes is the DRIVERS, which no binary can bring: libva
438
+ * plus the iHD media driver for Intel. Measured 2026-09-19 — the portable build
439
+ * encodes VAAPI inside our container and ABORTS on the bare Unraid host, where
440
+ * `libva-drm.so.2` does not exist.
311
441
  *
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.
442
+ * ## Absent is absent
315
443
  *
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.
444
+ * The naming carries the release, so two versions coexist during a change and a
445
+ * rollback is a file that is already on disk rather than a download.
319
446
  */
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
447
  /** Operator override. Unset on every node unless a human deliberately set it. */
323
448
  var FFMPEG_PATH_ENV = "CAMSTACK_FFMPEG_PATH";
324
449
  /**
325
- * The binary to use, or `null` when there is nothing yet and one must be
326
- * downloaded.
450
+ * The file name the pinned build is stored under.
451
+ *
452
+ * Versioned on purpose: a download is then idempotent, two releases can sit
453
+ * side by side while a change rolls through a cluster, and going back is a path
454
+ * that already exists instead of a fetch that has to succeed.
455
+ */
456
+ function ffmpegBinaryName(platform) {
457
+ return `ffmpeg-${FFMPEG_RELEASE}${platform === "win32" ? ".exe" : ""}`;
458
+ }
459
+ /**
460
+ * The binary to use, or `null` when it has to be downloaded first.
327
461
  *
328
462
  * An override that does not exist is NOT silently skipped — it is an operator
329
463
  * 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.
464
+ * would hide it. The caller reports it and carries on with the pinned build,
465
+ * which is the only safe direction: a typo must not stop a node serving video.
332
466
  */
333
467
  function chooseFfmpegBinary(input) {
334
468
  const override = input.override?.trim() ?? "";
@@ -336,121 +470,83 @@ function chooseFfmpegBinary(input) {
336
470
  origin: "override",
337
471
  path: override
338
472
  };
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 {
473
+ if (input.exists(input.pinnedPath)) return {
345
474
  origin: "downloaded",
346
- path: input.downloadedPath
475
+ path: input.pinnedPath
347
476
  };
348
477
  return null;
349
478
  }
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
479
  //#endregion
355
480
  //#region src/deps/ffmpeg-downloader.ts
356
481
  /**
357
- * The ffmpeg binary a node runs, and the static build we download when no image
358
- * provided one.
359
- *
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.
482
+ * Provisioning the ffmpeg binary for this node.
363
483
  *
364
- * Sources for the download:
365
- * - Linux: https://johnvansickle.com/ffmpeg/ (static builds)
366
- * - macOS: https://www.osxexperts.net/
484
+ * WHICH build, and why every node downloads the same one instead of using
485
+ * anything the machine already has, is in `ffmpeg-binary-source.ts` and
486
+ * `ffmpeg-artifacts.ts`. This module is the act: resolve, download if needed,
487
+ * probe, and say what it got.
367
488
  */
368
489
  /**
369
- * johnvansickle for linux (unversioned "release" URLs, so no version appears
370
- * here), osxexperts for macOS — two different builds, and the Intel one is a
371
- * different file at a different version from the arm one.
372
- */
373
- var FFMPEG_ARTIFACTS = {
374
- "linux-x64": {
375
- url: "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz",
376
- archiveFormat: "tar.xz",
377
- expectedHwaccels: []
378
- },
379
- "linux-arm64": {
380
- url: "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz",
381
- archiveFormat: "tar.xz",
382
- expectedHwaccels: []
383
- },
384
- "darwin-arm64": {
385
- url: "https://www.osxexperts.net/ffmpeg71arm.zip",
386
- archiveFormat: "zip",
387
- expectedHwaccels: ["videotoolbox"]
388
- },
389
- "darwin-x64": {
390
- url: "https://www.osxexperts.net/ffmpeg80intel.zip",
391
- archiveFormat: "zip",
392
- expectedHwaccels: ["videotoolbox"]
393
- }
394
- };
395
- /** The artifact for this node, or a refusal naming what was asked for. */
396
- function getFfmpegArtifact(platform, arch) {
397
- const artifact = FFMPEG_ARTIFACTS[`${platform}-${arch}`];
398
- if (!artifact) throw new Error(`Unsupported platform/architecture for ffmpeg: ${platform}-${arch}`);
399
- return artifact;
400
- }
401
- /** The URL alone, for a caller that wants nothing else. */
402
- function getFfmpegDownloadUrl(platform, arch) {
403
- return getFfmpegArtifact(platform, arch).url;
404
- }
405
- /**
406
- * Ensure an ffmpeg binary WE provide is available on this node, and report
407
- * which one and what it can do.
408
- *
409
- * Order: the operator's voluntary override → the one our image installed → a
410
- * copy we already downloaded → download it. The system PATH is not in that
411
- * list and must never be: see `ffmpeg-binary-source.ts`.
412
- *
413
- * The line it logs is the point as much as the path is. "Which ffmpeg am I
414
- * running, and does it have vaapi" had no answer anywhere in the product, and
415
- * that is what let a hardware-incapable binary serve every transcode on a hub
416
- * with a working iGPU for months, silently in software.
490
+ * The ffmpeg this node runs, downloading the pinned build if it is not here
491
+ * yet, and reporting what it got.
492
+ *
493
+ * Order: the operator's `CAMSTACK_FFMPEG_PATH` → the pinned build already on
494
+ * disk → download it. No PATH, no image copy, no per-platform special case —
495
+ * see `ffmpeg-binary-source.ts` for why the image stopped being special after
496
+ * exactly one day.
497
+ *
498
+ * The line it logs is half the point. "Which ffmpeg am I running, and does it
499
+ * have vaapi" had no answer anywhere in the product, and that is what let a
500
+ * hardware-incapable binary serve every transcode on a hub with a working iGPU
501
+ * for months. The other half is the assertion beneath it: a build that probes
502
+ * short of what its artifact claims is a source that changed under us, and it
503
+ * says so with both lists.
417
504
  */
418
505
  async function ensureFfmpeg(dataDir, logger) {
419
506
  const depsDir = join(dataDir, "deps");
420
507
  const platform = process.platform;
421
508
  const arch = process.arch;
422
- const ext = platform === "win32" ? ".exe" : "";
423
- const downloadedPath = join(depsDir, `ffmpeg${ext}`);
509
+ const pinnedPath = join(depsDir, ffmpegBinaryName(platform));
424
510
  const override = process.env[FFMPEG_PATH_ENV];
425
511
  const chosen = chooseFfmpegBinary({
426
512
  platform,
427
- downloadedPath,
513
+ pinnedPath,
428
514
  override,
429
515
  exists: existsSync
430
516
  });
431
517
  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 } });
432
518
  const artifact = chosen === null ? getFfmpegArtifact(platform, arch) : null;
519
+ if (artifact !== null) logger.info("ffmpeg is not on this node yet — downloading the pinned build", { meta: {
520
+ release: FFMPEG_RELEASE,
521
+ url: artifact.url,
522
+ sizeMb: Math.round(artifact.sizeBytes / 1e6)
523
+ } });
433
524
  const path = chosen?.path ?? await downloadBinary({
434
525
  name: "ffmpeg",
435
526
  url: artifact?.url ?? getFfmpegDownloadUrl(platform, arch),
436
527
  targetDir: depsDir,
437
- targetName: `ffmpeg${ext}`,
528
+ targetName: ffmpegBinaryName(platform),
438
529
  logger,
439
530
  isArchive: true,
440
- archiveFormat: artifact?.archiveFormat ?? "tar.xz"
531
+ archiveFormat: artifact?.archiveFormat ?? "tar.xz",
532
+ archiveInnerPath: "ffmpeg",
533
+ ...artifact === null ? {} : { sha256: artifact.sha256 }
441
534
  });
442
535
  const capabilities = await probeFfmpegBinary(path);
443
536
  logger.info("ffmpeg binary resolved", { meta: {
444
537
  path,
445
538
  origin: chosen?.origin ?? "downloaded",
539
+ release: FFMPEG_RELEASE,
446
540
  version: capabilities.version,
447
541
  hwaccels: [...capabilities.hwaccels].join(",") || "none"
448
542
  } });
449
- if (artifact !== null) {
450
- const missing = artifact.expectedHwaccels.filter((name) => !capabilities.hwaccels.has(name));
451
- if (missing.length > 0) logger.error("the ffmpeg we downloaded cannot do what this artifact is supposed to — the source changed under us", { meta: {
452
- url: artifact.url,
453
- expected: artifact.expectedHwaccels.join(","),
543
+ if (chosen?.origin !== "override") {
544
+ const expected = getFfmpegArtifact(platform, arch).expectedHwaccels;
545
+ const missing = expected.filter((name) => !capabilities.hwaccels.has(name));
546
+ if (missing.length > 0) logger.error("this ffmpeg cannot do what its artifact is supposed to — the source changed under us", { meta: {
547
+ path,
548
+ release: FFMPEG_RELEASE,
549
+ expected: expected.join(","),
454
550
  missing: missing.join(","),
455
551
  got: [...capabilities.hwaccels].join(",") || "none"
456
552
  } });
@@ -1937,4 +2033,4 @@ function resolveExportFingerprint(input) {
1937
2033
  return input.persisted ?? input.fresh;
1938
2034
  }
1939
2035
  //#endregion
1940
- export { ChildCostRegistry, FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, NO_COST_CLAIM, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, containsOrEquals, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegArtifact, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, nodeProcStatReader, parseFfmpegCapabilities, parseProcCpuSeconds, parseProcRssBytes, physicalRootOf, probeFfmpegBinary, readProcessCost, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
2036
+ export { ChildCostRegistry, FFMPEG_RELEASE, FfmpegProcess, FilesystemStorageProvider, Fmp4FragmentChild, Fmp4FragmentPlane, NO_COST_CLAIM, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, containsOrEquals, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegArtifact, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, nodeProcStatReader, parseFfmpegCapabilities, parseProcCpuSeconds, parseProcRssBytes, physicalRootOf, probeFfmpegBinary, readProcessCost, resolveExportFingerprint, signExpiringUrl, verifyExpiringUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/types",
3
- "version": "1.2.227",
3
+ "version": "1.2.228",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",