@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js +6 -1
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/chunkEncoder.d.ts +1 -1
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +55 -16
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/parallelCoordinator.d.ts.map +1 -1
- package/dist/services/parallelCoordinator.js +4 -3
- package/dist/services/parallelCoordinator.js.map +1 -1
- package/dist/services/streamingEncoder.d.ts +9 -2
- package/dist/services/streamingEncoder.d.ts.map +1 -1
- package/dist/services/streamingEncoder.js +55 -16
- package/dist/services/streamingEncoder.js.map +1 -1
- package/dist/services/systemMemory.d.ts +11 -8
- package/dist/services/systemMemory.d.ts.map +1 -1
- package/dist/services/systemMemory.js +110 -9
- package/dist/services/systemMemory.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +3 -1
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/utils/ffmpegBinaries.d.ts +6 -0
- package/dist/utils/ffmpegBinaries.d.ts.map +1 -0
- package/dist/utils/ffmpegBinaries.js +55 -0
- package/dist/utils/ffmpegBinaries.js.map +1 -0
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +8 -2
- package/dist/utils/ffprobe.js.map +1 -1
- package/dist/utils/gpuEncoder.d.ts.map +1 -1
- package/dist/utils/gpuEncoder.js +4 -2
- package/dist/utils/gpuEncoder.js.map +1 -1
- package/dist/utils/runFfmpeg.d.ts.map +1 -1
- package/dist/utils/runFfmpeg.js +20 -1
- package/dist/utils/runFfmpeg.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +7 -0
- package/src/services/browserManager.test.ts +18 -0
- package/src/services/browserManager.ts +8 -1
- package/src/services/chunkEncoder.test.ts +330 -1
- package/src/services/chunkEncoder.ts +75 -13
- package/src/services/parallelCoordinator.ts +4 -3
- package/src/services/streamingEncoder.test.ts +156 -10
- package/src/services/streamingEncoder.ts +73 -17
- package/src/services/systemMemory.test.ts +303 -2
- package/src/services/systemMemory.ts +137 -9
- package/src/services/videoFrameExtractor.ts +3 -1
- package/src/utils/ffmpegBinaries.test.ts +43 -0
- package/src/utils/ffmpegBinaries.ts +63 -0
- package/src/utils/ffprobe.test.ts +27 -0
- package/src/utils/ffprobe.ts +12 -2
- package/src/utils/gpuEncoder.ts +4 -2
- package/src/utils/runFfmpeg.test.ts +57 -1
- package/src/utils/runFfmpeg.ts +24 -1
|
@@ -1,5 +1,95 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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:
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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(
|
|
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 () => {
|
package/src/utils/ffprobe.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|