@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.
- package/dist/deps/binary-downloader.d.ts +12 -0
- package/dist/deps/ffmpeg-artifacts.d.ts +66 -0
- package/dist/deps/ffmpeg-binary-source.d.ts +43 -45
- package/dist/deps/ffmpeg-downloader.d.ts +12 -41
- package/dist/deps/index.d.ts +2 -1
- package/dist/node.d.ts +2 -1
- package/dist/node.js +216 -119
- package/dist/node.mjs +216 -120
- package/package.json +1 -1
|
@@ -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
|
|
2
|
+
* WHICH ffmpeg a node runs — one rule, on every node.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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' | '
|
|
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
|
|
58
|
-
readonly
|
|
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
|
|
65
|
-
*
|
|
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
|
|
70
|
-
* is the only safe direction: a typo must not stop a node
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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>;
|
package/dist/deps/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { ensureBinary, downloadBinary, findInPath, getPlatformInfo, buildBinaryPath, } from './binary-downloader.js';
|
|
2
|
-
export { ensureFfmpeg
|
|
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
|
|
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
|
|
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
|
|
431
|
+
* WHICH ffmpeg a node runs — one rule, on every node.
|
|
300
432
|
*
|
|
301
|
-
*
|
|
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
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
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
|
-
*
|
|
312
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
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
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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
|
-
*
|
|
332
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
340
|
-
*
|
|
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
|
|
349
|
-
*
|
|
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
|
|
354
|
-
* is the only safe direction: a typo must not stop a node
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
388
|
-
*
|
|
389
|
-
* -
|
|
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
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
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
|
|
408
|
+
* WHICH ffmpeg a node runs — one rule, on every node.
|
|
277
409
|
*
|
|
278
|
-
*
|
|
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
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
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
|
-
*
|
|
289
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
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
|
-
*
|
|
309
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
317
|
-
*
|
|
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
|
|
326
|
-
*
|
|
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
|
|
331
|
-
* is the only safe direction: a typo must not stop a node
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
365
|
-
*
|
|
366
|
-
* -
|
|
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
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 };
|