@hyperframes/engine 0.6.93 → 0.6.95

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.
Files changed (57) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/services/browserManager.d.ts.map +1 -1
  6. package/dist/services/browserManager.js +6 -1
  7. package/dist/services/browserManager.js.map +1 -1
  8. package/dist/services/chunkEncoder.d.ts +1 -1
  9. package/dist/services/chunkEncoder.d.ts.map +1 -1
  10. package/dist/services/chunkEncoder.js +55 -16
  11. package/dist/services/chunkEncoder.js.map +1 -1
  12. package/dist/services/parallelCoordinator.d.ts.map +1 -1
  13. package/dist/services/parallelCoordinator.js +4 -3
  14. package/dist/services/parallelCoordinator.js.map +1 -1
  15. package/dist/services/streamingEncoder.d.ts +9 -2
  16. package/dist/services/streamingEncoder.d.ts.map +1 -1
  17. package/dist/services/streamingEncoder.js +55 -16
  18. package/dist/services/streamingEncoder.js.map +1 -1
  19. package/dist/services/systemMemory.d.ts +11 -8
  20. package/dist/services/systemMemory.d.ts.map +1 -1
  21. package/dist/services/systemMemory.js +110 -9
  22. package/dist/services/systemMemory.js.map +1 -1
  23. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  24. package/dist/services/videoFrameExtractor.js +3 -1
  25. package/dist/services/videoFrameExtractor.js.map +1 -1
  26. package/dist/utils/ffmpegBinaries.d.ts +6 -0
  27. package/dist/utils/ffmpegBinaries.d.ts.map +1 -0
  28. package/dist/utils/ffmpegBinaries.js +55 -0
  29. package/dist/utils/ffmpegBinaries.js.map +1 -0
  30. package/dist/utils/ffprobe.d.ts.map +1 -1
  31. package/dist/utils/ffprobe.js +8 -2
  32. package/dist/utils/ffprobe.js.map +1 -1
  33. package/dist/utils/gpuEncoder.d.ts.map +1 -1
  34. package/dist/utils/gpuEncoder.js +4 -2
  35. package/dist/utils/gpuEncoder.js.map +1 -1
  36. package/dist/utils/runFfmpeg.d.ts.map +1 -1
  37. package/dist/utils/runFfmpeg.js +20 -1
  38. package/dist/utils/runFfmpeg.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/index.ts +7 -0
  41. package/src/services/browserManager.test.ts +18 -0
  42. package/src/services/browserManager.ts +8 -1
  43. package/src/services/chunkEncoder.test.ts +330 -1
  44. package/src/services/chunkEncoder.ts +75 -13
  45. package/src/services/parallelCoordinator.ts +4 -3
  46. package/src/services/streamingEncoder.test.ts +156 -10
  47. package/src/services/streamingEncoder.ts +73 -17
  48. package/src/services/systemMemory.test.ts +303 -2
  49. package/src/services/systemMemory.ts +137 -9
  50. package/src/services/videoFrameExtractor.ts +3 -1
  51. package/src/utils/ffmpegBinaries.test.ts +43 -0
  52. package/src/utils/ffmpegBinaries.ts +63 -0
  53. package/src/utils/ffprobe.test.ts +27 -0
  54. package/src/utils/ffprobe.ts +12 -2
  55. package/src/utils/gpuEncoder.ts +4 -2
  56. package/src/utils/runFfmpeg.test.ts +57 -1
  57. package/src/utils/runFfmpeg.ts +24 -1
@@ -1,5 +1,95 @@
1
- import { describe, it, expect } from "vitest";
2
- import { isLowMemorySystem, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
1
+ import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
2
+ import {
3
+ _resetCgroupLimitCacheForTests,
4
+ isLowMemorySystem,
5
+ LOW_MEMORY_TOTAL_MB_THRESHOLD,
6
+ parseCgroupLimitMb,
7
+ } from "./systemMemory.js";
8
+
9
+ const BYTES_PER_MIB = 1024 * 1024;
10
+ const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
11
+ const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
12
+
13
+ type SystemMemoryModule = typeof import("./systemMemory.js");
14
+
15
+ type MockSystemMemoryOptions = {
16
+ files?: Record<string, string>;
17
+ hostTotalMb?: number;
18
+ platform?: NodeJS.Platform;
19
+ readErrors?: Record<string, NodeJS.ErrnoException>;
20
+ onRead?: (path: string) => void;
21
+ throwOnFileRead?: boolean;
22
+ };
23
+
24
+ function stubPlatform(platform: NodeJS.Platform): () => void {
25
+ const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
26
+ Object.defineProperty(process, "platform", { value: platform });
27
+
28
+ return () => {
29
+ if (descriptor) {
30
+ Object.defineProperty(process, "platform", descriptor);
31
+ }
32
+ };
33
+ }
34
+
35
+ async function withSystemMemoryMocks(
36
+ options: MockSystemMemoryOptions,
37
+ run: (systemMemory: SystemMemoryModule) => void | Promise<void>,
38
+ ): Promise<void> {
39
+ const {
40
+ files = {},
41
+ hostTotalMb = 32768,
42
+ platform = "linux",
43
+ readErrors = {},
44
+ onRead,
45
+ throwOnFileRead = false,
46
+ } = options;
47
+ const restorePlatform = stubPlatform(platform);
48
+
49
+ vi.resetModules();
50
+ vi.doMock("os", () => ({
51
+ totalmem: () => hostTotalMb * BYTES_PER_MIB,
52
+ }));
53
+ vi.doMock("fs", () => ({
54
+ readFileSync: (path: string) => {
55
+ onRead?.(path);
56
+
57
+ if (throwOnFileRead) {
58
+ throw new Error(`/sys read should not happen: ${path}`);
59
+ }
60
+
61
+ if (path in readErrors) {
62
+ throw readErrors[path];
63
+ }
64
+
65
+ if (path in files) {
66
+ return files[path];
67
+ }
68
+
69
+ throw Object.assign(new Error("missing cgroup file"), { code: "ENOENT" });
70
+ },
71
+ }));
72
+
73
+ try {
74
+ const systemMemory = await import("./systemMemory.js");
75
+ systemMemory._resetCgroupLimitCacheForTests();
76
+ await run(systemMemory);
77
+ } finally {
78
+ vi.doUnmock("fs");
79
+ vi.doUnmock("os");
80
+ vi.resetModules();
81
+ restorePlatform();
82
+ }
83
+ }
84
+
85
+ beforeEach(() => {
86
+ _resetCgroupLimitCacheForTests();
87
+ });
88
+
89
+ afterEach(() => {
90
+ _resetCgroupLimitCacheForTests();
91
+ vi.restoreAllMocks();
92
+ });
3
93
 
4
94
  describe("isLowMemorySystem", () => {
5
95
  it("treats sub-threshold RAM as low-memory", () => {
@@ -20,4 +110,215 @@ describe("isLowMemorySystem", () => {
20
110
  expect(isLowMemorySystem(16384)).toBe(false);
21
111
  expect(isLowMemorySystem(65536)).toBe(false);
22
112
  });
113
+
114
+ it("treats a 4 GiB cgroup v2 limit on a 32 GiB host as low-memory", async () => {
115
+ await withSystemMemoryMocks(
116
+ {
117
+ files: {
118
+ [CGROUP_V2_MEMORY_MAX_PATH]: `${4096 * BYTES_PER_MIB}`,
119
+ },
120
+ },
121
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
122
+ expect(getSystemTotalMb()).toBe(4096);
123
+ expect(isLowMemorySystem()).toBe(true);
124
+ },
125
+ );
126
+ });
127
+ });
128
+
129
+ describe("parseCgroupLimitMb", () => {
130
+ it("parses cgroup v2 numeric limits", () => {
131
+ expect(parseCgroupLimitMb(`${4096 * BYTES_PER_MIB}`, null)).toBe(4096);
132
+ });
133
+
134
+ it('ignores cgroup v2 "max" limits', () => {
135
+ expect(parseCgroupLimitMb("max", null)).toBeNull();
136
+ });
137
+
138
+ it("parses cgroup v1 numeric limits and ignores no-limit sentinels", () => {
139
+ expect(parseCgroupLimitMb(null, `${6144 * BYTES_PER_MIB}`)).toBe(6144);
140
+ expect(parseCgroupLimitMb(null, "9223372036854771712")).toBeNull();
141
+ });
142
+
143
+ it("ignores absent and malformed limits", () => {
144
+ expect(parseCgroupLimitMb(null, null)).toBeNull();
145
+
146
+ for (const content of ["", "garbage", "-1", "0"]) {
147
+ expect(parseCgroupLimitMb(content, null)).toBeNull();
148
+ expect(parseCgroupLimitMb(null, content)).toBeNull();
149
+ }
150
+ });
151
+
152
+ it("uses cgroup v2 when both v2 and v1 contents are present", () => {
153
+ expect(parseCgroupLimitMb(`${4096 * BYTES_PER_MIB}`, `${2048 * BYTES_PER_MIB}`)).toBe(4096);
154
+ });
155
+ });
156
+
157
+ describe("getSystemTotalMb", () => {
158
+ it("caches cgroup probes until the test reset hook clears the cache", async () => {
159
+ const readCalls: string[] = [];
160
+ const files = {
161
+ [CGROUP_V2_MEMORY_MAX_PATH]: `${4096 * BYTES_PER_MIB}`,
162
+ };
163
+
164
+ await withSystemMemoryMocks(
165
+ {
166
+ files,
167
+ onRead: (path) => readCalls.push(path),
168
+ },
169
+ ({ _resetCgroupLimitCacheForTests, getSystemTotalMb }) => {
170
+ expect(getSystemTotalMb()).toBe(4096);
171
+ expect(getSystemTotalMb()).toBe(4096);
172
+ expect(readCalls).toEqual([CGROUP_V2_MEMORY_MAX_PATH]);
173
+
174
+ files[CGROUP_V2_MEMORY_MAX_PATH] = `${2048 * BYTES_PER_MIB}`;
175
+ _resetCgroupLimitCacheForTests();
176
+
177
+ expect(getSystemTotalMb()).toBe(2048);
178
+ expect(readCalls).toEqual([CGROUP_V2_MEMORY_MAX_PATH, CGROUP_V2_MEMORY_MAX_PATH]);
179
+ },
180
+ );
181
+ });
182
+
183
+ it('uses the host total when cgroup v2 reports "max"', async () => {
184
+ await withSystemMemoryMocks(
185
+ {
186
+ files: {
187
+ [CGROUP_V2_MEMORY_MAX_PATH]: "max",
188
+ },
189
+ },
190
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
191
+ expect(getSystemTotalMb()).toBe(32768);
192
+ expect(isLowMemorySystem()).toBe(false);
193
+ },
194
+ );
195
+ });
196
+
197
+ it("honors cgroup v1 numeric limits when cgroup v2 is absent", async () => {
198
+ await withSystemMemoryMocks(
199
+ {
200
+ files: {
201
+ [CGROUP_V1_MEMORY_LIMIT_PATH]: `${6144 * BYTES_PER_MIB}`,
202
+ },
203
+ },
204
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
205
+ expect(getSystemTotalMb()).toBe(6144);
206
+ expect(isLowMemorySystem()).toBe(true);
207
+ },
208
+ );
209
+ });
210
+
211
+ it("uses the host total when cgroup v1 reports a no-limit sentinel", async () => {
212
+ await withSystemMemoryMocks(
213
+ {
214
+ files: {
215
+ [CGROUP_V1_MEMORY_LIMIT_PATH]: "9223372036854771712",
216
+ },
217
+ },
218
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
219
+ expect(getSystemTotalMb()).toBe(32768);
220
+ expect(isLowMemorySystem()).toBe(false);
221
+ },
222
+ );
223
+ });
224
+
225
+ it("uses the host total when cgroup files are absent", async () => {
226
+ await withSystemMemoryMocks({}, ({ getSystemTotalMb, isLowMemorySystem }) => {
227
+ expect(getSystemTotalMb()).toBe(32768);
228
+ expect(isLowMemorySystem()).toBe(false);
229
+ });
230
+ });
231
+
232
+ it("warns once and uses the host total when a cgroup file is unreadable", async () => {
233
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
234
+ await withSystemMemoryMocks(
235
+ {
236
+ readErrors: {
237
+ [CGROUP_V2_MEMORY_MAX_PATH]: Object.assign(new Error("permission denied"), {
238
+ code: "EACCES",
239
+ }),
240
+ },
241
+ },
242
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
243
+ expect(getSystemTotalMb()).toBe(32768);
244
+ expect(getSystemTotalMb()).toBe(32768);
245
+ expect(isLowMemorySystem()).toBe(false);
246
+ expect(warn).toHaveBeenCalledTimes(1);
247
+ expect(warn.mock.calls[0]?.[0]).toContain(
248
+ "[SystemMemory] Unable to read cgroup memory limit",
249
+ );
250
+ expect(warn.mock.calls[0]?.[0]).toContain("EACCES");
251
+ },
252
+ );
253
+ });
254
+
255
+ it("stays silent when cgroup files are absent", async () => {
256
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
257
+ await withSystemMemoryMocks({}, ({ getSystemTotalMb }) => {
258
+ expect(getSystemTotalMb()).toBe(32768);
259
+ expect(getSystemTotalMb()).toBe(32768);
260
+ expect(warn).not.toHaveBeenCalled();
261
+ });
262
+ });
263
+
264
+ it.each(["", "garbage", "-1", "0"])(
265
+ "uses the host total for malformed cgroup v2 content %j",
266
+ async (content) => {
267
+ await withSystemMemoryMocks(
268
+ {
269
+ files: {
270
+ [CGROUP_V2_MEMORY_MAX_PATH]: content,
271
+ },
272
+ },
273
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
274
+ expect(getSystemTotalMb()).toBe(32768);
275
+ expect(isLowMemorySystem()).toBe(false);
276
+ },
277
+ );
278
+ },
279
+ );
280
+
281
+ it.each(["", "garbage", "-1", "0"])(
282
+ "uses the host total for malformed cgroup v1 content %j",
283
+ async (content) => {
284
+ await withSystemMemoryMocks(
285
+ {
286
+ files: {
287
+ [CGROUP_V1_MEMORY_LIMIT_PATH]: content,
288
+ },
289
+ },
290
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
291
+ expect(getSystemTotalMb()).toBe(32768);
292
+ expect(isLowMemorySystem()).toBe(false);
293
+ },
294
+ );
295
+ },
296
+ );
297
+
298
+ it("uses the host total when a cgroup limit is larger than host RAM", async () => {
299
+ await withSystemMemoryMocks(
300
+ {
301
+ files: {
302
+ [CGROUP_V2_MEMORY_MAX_PATH]: `${65536 * BYTES_PER_MIB}`,
303
+ },
304
+ },
305
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
306
+ expect(getSystemTotalMb()).toBe(32768);
307
+ expect(isLowMemorySystem()).toBe(false);
308
+ },
309
+ );
310
+ });
311
+
312
+ it("does not read cgroup files on non-Linux platforms", async () => {
313
+ await withSystemMemoryMocks(
314
+ {
315
+ platform: "darwin",
316
+ throwOnFileRead: true,
317
+ },
318
+ ({ getSystemTotalMb, isLowMemorySystem }) => {
319
+ expect(getSystemTotalMb()).toBe(32768);
320
+ expect(isLowMemorySystem()).toBe(false);
321
+ },
322
+ );
323
+ });
23
324
  });
@@ -8,11 +8,140 @@
8
8
  * it lives here once instead of being re-derived inline.
9
9
  */
10
10
 
11
+ import { readFileSync } from "fs";
11
12
  import { totalmem } from "os";
12
13
 
14
+ const BYTES_PER_MIB = 1024 * 1024;
15
+ const BYTES_PER_MIB_BIGINT = BigInt(BYTES_PER_MIB);
16
+ // These are the paths as seen from INSIDE a container, where the runtime
17
+ // mounts the container's own cgroup at the namespace root — the case this
18
+ // probe exists for. They are deliberately not resolved via /proc/self/cgroup:
19
+ // on a bare host under systemd the process's real limit may live in a nested
20
+ // slice (e.g. /sys/fs/cgroup/user.slice/.../memory.max) that these root paths
21
+ // don't see, and that's acceptable — bare hosts are covered by total-RAM
22
+ // detection, and chasing nested slices adds fragility for no container gain.
23
+ const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
24
+ const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
25
+ // Kernel no-limit sentinel is page-rounded 2^63-1 (~9223372036854771712); >= 2^60 is implausible as a real limit.
26
+ const CGROUP_V1_NO_LIMIT_CUTOFF_BYTES = 2n ** 60n;
27
+
28
+ let _cachedCgroupLimitMb: number | null | undefined;
29
+ let _warnedCgroupReadFailure = false;
30
+
31
+ /** Parse cgroup v2/v1 memory limits from sysfs file contents into MiB. */
32
+ export function parseCgroupLimitMb(
33
+ v2Content: string | null,
34
+ v1Content: string | null,
35
+ ): number | null {
36
+ if (v2Content !== null) {
37
+ return parseCgroupV2LimitMb(v2Content);
38
+ }
39
+
40
+ return parseCgroupV1LimitMb(v1Content);
41
+ }
42
+
43
+ function parseCgroupV2LimitMb(content: string): number | null {
44
+ const trimmed = content.trim();
45
+ if (trimmed === "max") {
46
+ return null;
47
+ }
48
+
49
+ return parsePositiveByteLimitMb(trimmed);
50
+ }
51
+
52
+ function parseCgroupV1LimitMb(content: string | null): number | null {
53
+ if (content === null) {
54
+ return null;
55
+ }
56
+
57
+ return parsePositiveByteLimitMb(content.trim(), CGROUP_V1_NO_LIMIT_CUTOFF_BYTES);
58
+ }
59
+
60
+ function parsePositiveByteLimitMb(content: string, noLimitCutoffBytes?: bigint): number | null {
61
+ if (!/^\d+$/.test(content)) {
62
+ return null;
63
+ }
64
+
65
+ const bytes = BigInt(content);
66
+ if (bytes <= 0n) {
67
+ return null;
68
+ }
69
+
70
+ if (noLimitCutoffBytes !== undefined && bytes >= noLimitCutoffBytes) {
71
+ return null;
72
+ }
73
+
74
+ return Number(bytes / BYTES_PER_MIB_BIGINT);
75
+ }
76
+
77
+ /** Test-only: reset the cached cgroup memory probe. */
78
+ export function _resetCgroupLimitCacheForTests(): void {
79
+ _cachedCgroupLimitMb = undefined;
80
+ _warnedCgroupReadFailure = false;
81
+ }
82
+
83
+ function getCgroupLimitMb(): number | null {
84
+ if (_cachedCgroupLimitMb !== undefined) return _cachedCgroupLimitMb;
85
+
86
+ if (process.platform !== "linux") {
87
+ _cachedCgroupLimitMb = null;
88
+ return null;
89
+ }
90
+
91
+ const v2Content = readCgroupFile(CGROUP_V2_MEMORY_MAX_PATH);
92
+ const v1Content = v2Content === null ? readCgroupFile(CGROUP_V1_MEMORY_LIMIT_PATH) : null;
93
+
94
+ _cachedCgroupLimitMb = parseCgroupLimitMb(v2Content, v1Content);
95
+ if (_cachedCgroupLimitMb !== null) {
96
+ console.info(
97
+ `[SystemMemory] cgroup memory limit detected: ${_cachedCgroupLimitMb} MiB — ` +
98
+ `it governs memory-adaptive render behaviour instead of host RAM.`,
99
+ );
100
+ }
101
+ return _cachedCgroupLimitMb;
102
+ }
103
+
104
+ function readCgroupFile(path: string): string | null {
105
+ try {
106
+ return readFileSync(path, "utf8");
107
+ } catch (error) {
108
+ const code = getErrorCode(error);
109
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
110
+ warnCgroupReadFailure(path, error);
111
+ }
112
+ return null;
113
+ }
114
+ }
115
+
116
+ function getErrorCode(error: unknown): string | undefined {
117
+ if (typeof error !== "object" || error === null || !("code" in error)) {
118
+ return undefined;
119
+ }
120
+
121
+ return typeof error.code === "string" ? error.code : undefined;
122
+ }
123
+
124
+ function formatCgroupReadError(error: unknown): string {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ const code = getErrorCode(error);
127
+ return code ? `${code}: ${message}` : message;
128
+ }
129
+
130
+ function warnCgroupReadFailure(path: string, error: unknown): void {
131
+ if (_warnedCgroupReadFailure) return;
132
+ _warnedCgroupReadFailure = true;
133
+ console.warn(
134
+ `[SystemMemory] Unable to read cgroup memory limit at ${path} ` +
135
+ `(${formatCgroupReadError(error)}); falling back to host RAM.`,
136
+ );
137
+ }
138
+
13
139
  /** Total physical RAM in MiB. */
14
140
  export function getSystemTotalMb(): number {
15
- return Math.floor(totalmem() / (1024 * 1024));
141
+ const hostTotalMb = Math.floor(totalmem() / BYTES_PER_MIB);
142
+ const cgroupLimitMb = getCgroupLimitMb();
143
+
144
+ return cgroupLimitMb === null ? hostTotalMb : Math.min(hostTotalMb, cgroupLimitMb);
16
145
  }
17
146
 
18
147
  /**
@@ -38,14 +167,13 @@ export const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
38
167
  * survive". Accepts an explicit `totalMb` so callers (and tests) can pass
39
168
  * a known value instead of re-probing.
40
169
  *
41
- * Caveat: `os.totalmem()` reports the *host's* physical RAM, not a
42
- * cgroup/container memory limit. A 4 GB container on a 32 GB host will not
43
- * auto-flag as low-memory, and an 8 GB container on a 64 GB host won't
44
- * either. Containerised and serverless callers (Docker `--docker` renders,
45
- * Lambda) that want a specific profile should set `PRODUCER_LOW_MEMORY_MODE`
46
- * explicitly rather than relying on auto-detection. Hosts whose *total* RAM
47
- * is genuinely <= the threshold (laptops, small VMs, small Lambda tiers) are
48
- * detected correctly regardless of container nesting.
170
+ * Caveat: Linux cgroup v1/v2 memory limits are consulted when readable, so
171
+ * Docker and serverless runtimes, including Lambda tiers with readable cgroup
172
+ * ceilings, inherit the tighter container limit instead of the host's physical
173
+ * RAM. Environments that hide cgroup files should set
174
+ * `PRODUCER_LOW_MEMORY_MODE` explicitly rather than relying on auto-detection.
175
+ * Hosts whose *effective* total RAM is genuinely <= the threshold (laptops,
176
+ * small VMs, small Lambda tiers, small containers) are detected correctly.
49
177
  */
50
178
  export function isLowMemorySystem(totalMb: number = getSystemTotalMb()): boolean {
51
179
  return totalMb <= LOW_MEMORY_TOTAL_MB_THRESHOLD;
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file unused-class-member code-duplication complexity
1
2
  /**
2
3
  * Video Frame Extractor Service
3
4
  *
@@ -19,6 +20,7 @@ import {
19
20
  } from "../utils/hdr.js";
20
21
  import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
21
22
  import { runFfmpeg } from "../utils/runFfmpeg.js";
23
+ import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
22
24
  import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
23
25
  import { unwrapTemplate } from "../utils/htmlTemplate.js";
24
26
  import {
@@ -259,7 +261,7 @@ export async function extractVideoFramesRange(
259
261
  args.push("-y", outputPattern);
260
262
 
261
263
  return new Promise((resolve, reject) => {
262
- const ffmpeg = spawn("ffmpeg", args);
264
+ const ffmpeg = spawn(getFfmpegBinary(), args);
263
265
  trackChildProcess(ffmpeg);
264
266
  let stderr = "";
265
267
  const onAbort = () => {
@@ -0,0 +1,43 @@
1
+ // fallow-ignore-file code-duplication
2
+ import { resolve } from "node:path";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import {
5
+ assertConfiguredFfmpegBinariesExist,
6
+ getFfmpegBinary,
7
+ getFfprobeBinary,
8
+ } from "./ffmpegBinaries.js";
9
+
10
+ describe("ffmpeg binary env resolution", () => {
11
+ const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
12
+ const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
13
+
14
+ afterEach(() => {
15
+ if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
16
+ else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
17
+ if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
18
+ else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
19
+ });
20
+
21
+ it("uses configured absolute paths when env vars are set", () => {
22
+ process.env.HYPERFRAMES_FFMPEG_PATH = "/tools/ffmpeg.exe";
23
+ process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
24
+
25
+ expect(getFfmpegBinary()).toBe(resolve("/tools/ffmpeg.exe"));
26
+ expect(getFfprobeBinary()).toBe(resolve("/tools/ffprobe.exe"));
27
+ });
28
+
29
+ it("throws a clear error when a configured FFmpeg path is missing", () => {
30
+ process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg.exe";
31
+
32
+ expect(() => assertConfiguredFfmpegBinariesExist()).toThrow(
33
+ /FFmpeg binary not found at HYPERFRAMES_FFMPEG_PATH/,
34
+ );
35
+ });
36
+
37
+ it("accepts existing configured paths", () => {
38
+ process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;
39
+ process.env.HYPERFRAMES_FFPROBE_PATH = process.execPath;
40
+
41
+ expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow();
42
+ });
43
+ });
@@ -0,0 +1,63 @@
1
+ // fallow-ignore-file code-duplication
2
+ import { execFileSync } from "child_process";
3
+ import { existsSync } from "fs";
4
+ import { resolve } from "path";
5
+
6
+ export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
7
+ export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
8
+
9
+ const pathCache = new Map<string, string | undefined>();
10
+
11
+ function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
12
+ if (pathCache.has(name)) return pathCache.get(name);
13
+ try {
14
+ const command = process.platform === "win32" ? "where" : "which";
15
+ const output = execFileSync(command, [name], {
16
+ encoding: "utf-8",
17
+ stdio: ["pipe", "pipe", "pipe"],
18
+ timeout: 5000,
19
+ });
20
+ const first = output
21
+ .split(/\r?\n/)
22
+ .map((s) => s.trim())
23
+ .find(Boolean);
24
+ const resolved = first ? resolve(first) : undefined;
25
+ pathCache.set(name, resolved);
26
+ return resolved;
27
+ } catch {
28
+ pathCache.set(name, undefined);
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ function getConfiguredBinary(envName: string, binaryName: "ffmpeg" | "ffprobe"): string {
34
+ const configured = process.env[envName]?.trim();
35
+ if (configured) return resolve(configured);
36
+ return findOnPath(binaryName) ?? binaryName;
37
+ }
38
+
39
+ export function getFfmpegBinary(): string {
40
+ return getConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
41
+ }
42
+
43
+ export function getFfprobeBinary(): string {
44
+ return getConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
45
+ }
46
+
47
+ export function assertConfiguredFfmpegBinariesExist(): void {
48
+ const ffmpegPath = process.env[FFMPEG_PATH_ENV]?.trim();
49
+ if (ffmpegPath && !existsSync(ffmpegPath)) {
50
+ throw new Error(
51
+ `[FFmpeg] FFmpeg binary not found at ${FFMPEG_PATH_ENV}="${ffmpegPath}". ` +
52
+ "Install FFmpeg or unset the override.",
53
+ );
54
+ }
55
+
56
+ const ffprobePath = process.env[FFPROBE_PATH_ENV]?.trim();
57
+ if (ffprobePath && !existsSync(ffprobePath)) {
58
+ throw new Error(
59
+ `[FFmpeg] FFprobe binary not found at ${FFPROBE_PATH_ENV}="${ffprobePath}". ` +
60
+ "Install FFmpeg or unset the override.",
61
+ );
62
+ }
63
+ }
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { EventEmitter } from "events";
2
3
  import { readFileSync } from "fs";
3
4
  import { resolve } from "path";
@@ -164,9 +165,35 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): {
164
165
  }
165
166
 
166
167
  describe("ffprobe missing-binary fallback", () => {
168
+ const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
169
+
167
170
  afterEach(() => {
168
171
  vi.resetModules();
169
172
  vi.doUnmock("child_process");
173
+ if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
174
+ else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
175
+ });
176
+
177
+ it("spawns the configured absolute FFprobe path when HYPERFRAMES_FFPROBE_PATH is set", async () => {
178
+ process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
179
+ const { spawn, calls } = createSpawnSpy([
180
+ {
181
+ kind: "exit",
182
+ code: 0,
183
+ stdout: JSON.stringify({
184
+ streams: [{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 }],
185
+ format: { duration: "1.25", bit_rate: "128000" },
186
+ }),
187
+ },
188
+ ]);
189
+ vi.resetModules();
190
+ vi.doMock("child_process", () => ({ spawn }));
191
+
192
+ const { extractAudioMetadata } = await import("./ffprobe.js");
193
+ const meta = await extractAudioMetadata("/tmp/uses-configured-ffprobe.wav");
194
+
195
+ expect(meta.durationSeconds).toBe(1.25);
196
+ expect(calls[0]?.command).toBe(resolve("/tools/ffprobe.exe"));
170
197
  });
171
198
 
172
199
  it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
@@ -1,11 +1,14 @@
1
+ // fallow-ignore-file code-duplication complexity
1
2
  import { spawn } from "child_process";
2
3
  import { readFileSync } from "fs";
3
4
  import { extname } from "path";
5
+ import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
4
6
 
5
7
  /** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
6
8
  function runFfprobe(args: string[]): Promise<string> {
7
9
  return new Promise((resolve, reject) => {
8
- const proc = spawn("ffprobe", args);
10
+ const command = getFfprobeBinary();
11
+ const proc = spawn(command, args);
9
12
  let stdout = "";
10
13
  let stderr = "";
11
14
  proc.stdout.on("data", (data) => {
@@ -23,7 +26,14 @@ function runFfprobe(args: string[]): Promise<string> {
23
26
  });
24
27
  proc.on("error", (err) => {
25
28
  if ((err as NodeJS.ErrnoException).code === "ENOENT") {
26
- reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
29
+ const configured = process.env[FFPROBE_PATH_ENV]?.trim();
30
+ reject(
31
+ new Error(
32
+ configured
33
+ ? `[FFmpeg] ffprobe not found at ${FFPROBE_PATH_ENV}="${configured}". Please install FFmpeg.`
34
+ : "[FFmpeg] ffprobe not found. Please install FFmpeg.",
35
+ ),
36
+ );
27
37
  } else {
28
38
  reject(err);
29
39
  }