@hyperframes/engine 0.6.94 → 0.6.96
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/services/chunkEncoder.d.ts +1 -1
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +50 -13
- 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 +52 -15
- 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/package.json +2 -2
- package/src/services/chunkEncoder.test.ts +330 -1
- package/src/services/chunkEncoder.ts +70 -10
- package/src/services/parallelCoordinator.ts +4 -3
- package/src/services/streamingEncoder.test.ts +156 -10
- package/src/services/streamingEncoder.ts +70 -16
- package/src/services/systemMemory.test.ts +303 -2
- package/src/services/systemMemory.ts +137 -9
|
@@ -7,10 +7,112 @@
|
|
|
7
7
|
* They all need the same "how much memory does this box have" reading, so
|
|
8
8
|
* it lives here once instead of being re-derived inline.
|
|
9
9
|
*/
|
|
10
|
+
import { readFileSync } from "fs";
|
|
10
11
|
import { totalmem } from "os";
|
|
12
|
+
const BYTES_PER_MIB = 1024 * 1024;
|
|
13
|
+
const BYTES_PER_MIB_BIGINT = BigInt(BYTES_PER_MIB);
|
|
14
|
+
// These are the paths as seen from INSIDE a container, where the runtime
|
|
15
|
+
// mounts the container's own cgroup at the namespace root — the case this
|
|
16
|
+
// probe exists for. They are deliberately not resolved via /proc/self/cgroup:
|
|
17
|
+
// on a bare host under systemd the process's real limit may live in a nested
|
|
18
|
+
// slice (e.g. /sys/fs/cgroup/user.slice/.../memory.max) that these root paths
|
|
19
|
+
// don't see, and that's acceptable — bare hosts are covered by total-RAM
|
|
20
|
+
// detection, and chasing nested slices adds fragility for no container gain.
|
|
21
|
+
const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
|
|
22
|
+
const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
|
|
23
|
+
// Kernel no-limit sentinel is page-rounded 2^63-1 (~9223372036854771712); >= 2^60 is implausible as a real limit.
|
|
24
|
+
const CGROUP_V1_NO_LIMIT_CUTOFF_BYTES = 2n ** 60n;
|
|
25
|
+
let _cachedCgroupLimitMb;
|
|
26
|
+
let _warnedCgroupReadFailure = false;
|
|
27
|
+
/** Parse cgroup v2/v1 memory limits from sysfs file contents into MiB. */
|
|
28
|
+
export function parseCgroupLimitMb(v2Content, v1Content) {
|
|
29
|
+
if (v2Content !== null) {
|
|
30
|
+
return parseCgroupV2LimitMb(v2Content);
|
|
31
|
+
}
|
|
32
|
+
return parseCgroupV1LimitMb(v1Content);
|
|
33
|
+
}
|
|
34
|
+
function parseCgroupV2LimitMb(content) {
|
|
35
|
+
const trimmed = content.trim();
|
|
36
|
+
if (trimmed === "max") {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return parsePositiveByteLimitMb(trimmed);
|
|
40
|
+
}
|
|
41
|
+
function parseCgroupV1LimitMb(content) {
|
|
42
|
+
if (content === null) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return parsePositiveByteLimitMb(content.trim(), CGROUP_V1_NO_LIMIT_CUTOFF_BYTES);
|
|
46
|
+
}
|
|
47
|
+
function parsePositiveByteLimitMb(content, noLimitCutoffBytes) {
|
|
48
|
+
if (!/^\d+$/.test(content)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const bytes = BigInt(content);
|
|
52
|
+
if (bytes <= 0n) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (noLimitCutoffBytes !== undefined && bytes >= noLimitCutoffBytes) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return Number(bytes / BYTES_PER_MIB_BIGINT);
|
|
59
|
+
}
|
|
60
|
+
/** Test-only: reset the cached cgroup memory probe. */
|
|
61
|
+
export function _resetCgroupLimitCacheForTests() {
|
|
62
|
+
_cachedCgroupLimitMb = undefined;
|
|
63
|
+
_warnedCgroupReadFailure = false;
|
|
64
|
+
}
|
|
65
|
+
function getCgroupLimitMb() {
|
|
66
|
+
if (_cachedCgroupLimitMb !== undefined)
|
|
67
|
+
return _cachedCgroupLimitMb;
|
|
68
|
+
if (process.platform !== "linux") {
|
|
69
|
+
_cachedCgroupLimitMb = null;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const v2Content = readCgroupFile(CGROUP_V2_MEMORY_MAX_PATH);
|
|
73
|
+
const v1Content = v2Content === null ? readCgroupFile(CGROUP_V1_MEMORY_LIMIT_PATH) : null;
|
|
74
|
+
_cachedCgroupLimitMb = parseCgroupLimitMb(v2Content, v1Content);
|
|
75
|
+
if (_cachedCgroupLimitMb !== null) {
|
|
76
|
+
console.info(`[SystemMemory] cgroup memory limit detected: ${_cachedCgroupLimitMb} MiB — ` +
|
|
77
|
+
`it governs memory-adaptive render behaviour instead of host RAM.`);
|
|
78
|
+
}
|
|
79
|
+
return _cachedCgroupLimitMb;
|
|
80
|
+
}
|
|
81
|
+
function readCgroupFile(path) {
|
|
82
|
+
try {
|
|
83
|
+
return readFileSync(path, "utf8");
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const code = getErrorCode(error);
|
|
87
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
88
|
+
warnCgroupReadFailure(path, error);
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function getErrorCode(error) {
|
|
94
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return typeof error.code === "string" ? error.code : undefined;
|
|
98
|
+
}
|
|
99
|
+
function formatCgroupReadError(error) {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
+
const code = getErrorCode(error);
|
|
102
|
+
return code ? `${code}: ${message}` : message;
|
|
103
|
+
}
|
|
104
|
+
function warnCgroupReadFailure(path, error) {
|
|
105
|
+
if (_warnedCgroupReadFailure)
|
|
106
|
+
return;
|
|
107
|
+
_warnedCgroupReadFailure = true;
|
|
108
|
+
console.warn(`[SystemMemory] Unable to read cgroup memory limit at ${path} ` +
|
|
109
|
+
`(${formatCgroupReadError(error)}); falling back to host RAM.`);
|
|
110
|
+
}
|
|
11
111
|
/** Total physical RAM in MiB. */
|
|
12
112
|
export function getSystemTotalMb() {
|
|
13
|
-
|
|
113
|
+
const hostTotalMb = Math.floor(totalmem() / BYTES_PER_MIB);
|
|
114
|
+
const cgroupLimitMb = getCgroupLimitMb();
|
|
115
|
+
return cgroupLimitMb === null ? hostTotalMb : Math.min(hostTotalMb, cgroupLimitMb);
|
|
14
116
|
}
|
|
15
117
|
/**
|
|
16
118
|
* Total-RAM ceiling (MiB) at or below which the host is treated as
|
|
@@ -34,14 +136,13 @@ export const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
|
|
|
34
136
|
* survive". Accepts an explicit `totalMb` so callers (and tests) can pass
|
|
35
137
|
* a known value instead of re-probing.
|
|
36
138
|
*
|
|
37
|
-
* Caveat:
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* detected correctly regardless of container nesting.
|
|
139
|
+
* Caveat: Linux cgroup v1/v2 memory limits are consulted when readable, so
|
|
140
|
+
* Docker and serverless runtimes, including Lambda tiers with readable cgroup
|
|
141
|
+
* ceilings, inherit the tighter container limit instead of the host's physical
|
|
142
|
+
* RAM. Environments that hide cgroup files should set
|
|
143
|
+
* `PRODUCER_LOW_MEMORY_MODE` explicitly rather than relying on auto-detection.
|
|
144
|
+
* Hosts whose *effective* total RAM is genuinely <= the threshold (laptops,
|
|
145
|
+
* small VMs, small Lambda tiers, small containers) are detected correctly.
|
|
45
146
|
*/
|
|
46
147
|
export function isLowMemorySystem(totalMb = getSystemTotalMb()) {
|
|
47
148
|
return totalMb <= LOW_MEMORY_TOTAL_MB_THRESHOLD;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"systemMemory.js","sourceRoot":"","sources":["../../src/services/systemMemory.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,IAAI,CAAC;AAE9B,
|
|
1
|
+
{"version":3,"file":"systemMemory.js","sourceRoot":"","sources":["../../src/services/systemMemory.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.96",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"linkedom": "^0.18.12",
|
|
22
22
|
"puppeteer": "^24.0.0",
|
|
23
23
|
"puppeteer-core": "^24.39.1",
|
|
24
|
-
"@hyperframes/core": "^0.6.
|
|
24
|
+
"@hyperframes/core": "^0.6.96"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^25.0.10",
|
|
@@ -1,6 +1,93 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
2
6
|
import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js";
|
|
3
7
|
|
|
8
|
+
const TINY_PNG = Buffer.from(
|
|
9
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAEElEQVR4nGP8wwACLGCSAQANBAECv1AVswAAAABJRU5ErkJggg==",
|
|
10
|
+
"base64",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const tempDirs: string[] = [];
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
for (const dir of tempDirs.splice(0)) {
|
|
17
|
+
rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
vi.doUnmock("child_process");
|
|
21
|
+
vi.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function createFrameFixture(): { root: string; framesDir: string } {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), "hf-chunk-encoder-"));
|
|
26
|
+
tempDirs.push(root);
|
|
27
|
+
const framesDir = join(root, "frames");
|
|
28
|
+
mkdirSync(framesDir);
|
|
29
|
+
for (let i = 1; i <= 2; i++) {
|
|
30
|
+
writeFileSync(join(framesDir, `frame_${String(i).padStart(6, "0")}.png`), TINY_PNG);
|
|
31
|
+
}
|
|
32
|
+
return { root, framesDir };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tinyEncodeOptions = {
|
|
36
|
+
fps: { num: 30, den: 1 },
|
|
37
|
+
width: 2,
|
|
38
|
+
height: 2,
|
|
39
|
+
codec: "h264" as const,
|
|
40
|
+
preset: "ultrafast",
|
|
41
|
+
quality: 28,
|
|
42
|
+
pixelFormat: "yuv420p",
|
|
43
|
+
useGpu: false,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function encodeTimeoutMessage(timeoutMs: number): string {
|
|
47
|
+
return `FFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms)`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type FakeProc = EventEmitter & {
|
|
51
|
+
stderr: EventEmitter;
|
|
52
|
+
kill: ReturnType<typeof vi.fn>;
|
|
53
|
+
killed: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type SpawnCall = {
|
|
57
|
+
command: string;
|
|
58
|
+
args: readonly string[];
|
|
59
|
+
proc: FakeProc;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function createFakeProc(): FakeProc {
|
|
63
|
+
const proc = new EventEmitter() as FakeProc;
|
|
64
|
+
proc.stderr = new EventEmitter();
|
|
65
|
+
proc.kill = vi.fn(() => {
|
|
66
|
+
proc.killed = true;
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
proc.killed = false;
|
|
70
|
+
return proc;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createSpawnSpy(): {
|
|
74
|
+
spawn: (command: string, args: readonly string[]) => FakeProc;
|
|
75
|
+
calls: SpawnCall[];
|
|
76
|
+
} {
|
|
77
|
+
const calls: SpawnCall[] = [];
|
|
78
|
+
const spawn = (command: string, args: readonly string[]): FakeProc => {
|
|
79
|
+
const proc = createFakeProc();
|
|
80
|
+
calls.push({ command, args, proc });
|
|
81
|
+
return proc;
|
|
82
|
+
};
|
|
83
|
+
return { spawn, calls };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function emitClose(proc: FakeProc, code: number): void {
|
|
87
|
+
proc.emit("exit", code);
|
|
88
|
+
proc.emit("close", code);
|
|
89
|
+
}
|
|
90
|
+
|
|
4
91
|
describe("ENCODER_PRESETS", () => {
|
|
5
92
|
it("has draft, standard, and high presets", () => {
|
|
6
93
|
expect(ENCODER_PRESETS).toHaveProperty("draft");
|
|
@@ -26,6 +113,248 @@ describe("ENCODER_PRESETS", () => {
|
|
|
26
113
|
});
|
|
27
114
|
});
|
|
28
115
|
|
|
116
|
+
describe("encodeFramesFromDir ffmpegEncodeTimeout", () => {
|
|
117
|
+
it("kills ffmpeg when config timeout elapses", async () => {
|
|
118
|
+
vi.useFakeTimers();
|
|
119
|
+
const { spawn, calls } = createSpawnSpy();
|
|
120
|
+
vi.resetModules();
|
|
121
|
+
vi.doMock("child_process", () => ({ spawn }));
|
|
122
|
+
|
|
123
|
+
const { encodeFramesFromDir } = await import("./chunkEncoder.js");
|
|
124
|
+
const { root, framesDir } = createFrameFixture();
|
|
125
|
+
|
|
126
|
+
const encodePromise = encodeFramesFromDir(
|
|
127
|
+
framesDir,
|
|
128
|
+
"frame_%06d.png",
|
|
129
|
+
join(root, "timeout.mp4"),
|
|
130
|
+
tinyEncodeOptions,
|
|
131
|
+
undefined,
|
|
132
|
+
{ ffmpegEncodeTimeout: 1000 },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(calls).toHaveLength(1);
|
|
136
|
+
const proc = calls[0]!.proc;
|
|
137
|
+
vi.advanceTimersByTime(999);
|
|
138
|
+
expect(proc.kill).not.toHaveBeenCalled();
|
|
139
|
+
|
|
140
|
+
vi.advanceTimersByTime(1);
|
|
141
|
+
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
142
|
+
|
|
143
|
+
proc.stderr.emit("data", Buffer.from("terminated by timeout\n"));
|
|
144
|
+
emitClose(proc, 143);
|
|
145
|
+
|
|
146
|
+
const result = await encodePromise;
|
|
147
|
+
expect(result.success).toBe(false);
|
|
148
|
+
expect(result.error).toContain("FFmpeg exited with code 143");
|
|
149
|
+
expect(result.error).toContain("terminated by timeout");
|
|
150
|
+
expect(result.error).toContain(encodeTimeoutMessage(1000));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("keeps non-timeout ffmpeg failures unchanged", async () => {
|
|
154
|
+
vi.useFakeTimers();
|
|
155
|
+
const { spawn, calls } = createSpawnSpy();
|
|
156
|
+
vi.resetModules();
|
|
157
|
+
vi.doMock("child_process", () => ({ spawn }));
|
|
158
|
+
|
|
159
|
+
const { encodeFramesFromDir } = await import("./chunkEncoder.js");
|
|
160
|
+
const { root, framesDir } = createFrameFixture();
|
|
161
|
+
|
|
162
|
+
const encodePromise = encodeFramesFromDir(
|
|
163
|
+
framesDir,
|
|
164
|
+
"frame_%06d.png",
|
|
165
|
+
join(root, "failure.mp4"),
|
|
166
|
+
tinyEncodeOptions,
|
|
167
|
+
undefined,
|
|
168
|
+
{ ffmpegEncodeTimeout: 1000 },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(calls).toHaveLength(1);
|
|
172
|
+
const proc = calls[0]!.proc;
|
|
173
|
+
proc.stderr.emit("data", Buffer.from("encoder failed\n"));
|
|
174
|
+
emitClose(proc, 1);
|
|
175
|
+
|
|
176
|
+
const result = await encodePromise;
|
|
177
|
+
expect(result.success).toBe(false);
|
|
178
|
+
expect(result.error).toContain("FFmpeg exited with code 1");
|
|
179
|
+
expect(result.error).toContain("encoder failed");
|
|
180
|
+
expect(result.error).not.toContain("ffmpegEncodeTimeout");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("uses the default timeout when config is omitted", async () => {
|
|
184
|
+
vi.useFakeTimers();
|
|
185
|
+
const { spawn, calls } = createSpawnSpy();
|
|
186
|
+
vi.resetModules();
|
|
187
|
+
vi.doMock("child_process", () => ({ spawn }));
|
|
188
|
+
|
|
189
|
+
const { encodeFramesFromDir } = await import("./chunkEncoder.js");
|
|
190
|
+
const { root, framesDir } = createFrameFixture();
|
|
191
|
+
|
|
192
|
+
const encodePromise = encodeFramesFromDir(
|
|
193
|
+
framesDir,
|
|
194
|
+
"frame_%06d.png",
|
|
195
|
+
join(root, "default.mp4"),
|
|
196
|
+
tinyEncodeOptions,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(calls).toHaveLength(1);
|
|
200
|
+
const proc = calls[0]!.proc;
|
|
201
|
+
vi.advanceTimersByTime(599_999);
|
|
202
|
+
expect(proc.kill).not.toHaveBeenCalled();
|
|
203
|
+
|
|
204
|
+
emitClose(proc, 0);
|
|
205
|
+
|
|
206
|
+
const result = await encodePromise;
|
|
207
|
+
expect(result.success).toBe(true);
|
|
208
|
+
expect(result.framesEncoded).toBe(2);
|
|
209
|
+
expect(result.fileSize).toBe(0);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => {
|
|
214
|
+
it("passes config timeout to per-chunk encodes", async () => {
|
|
215
|
+
vi.useFakeTimers();
|
|
216
|
+
const { spawn, calls } = createSpawnSpy();
|
|
217
|
+
vi.resetModules();
|
|
218
|
+
vi.doMock("child_process", () => ({ spawn }));
|
|
219
|
+
|
|
220
|
+
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
|
|
221
|
+
const { root, framesDir } = createFrameFixture();
|
|
222
|
+
|
|
223
|
+
const encodePromise = encodeFramesChunkedConcat(
|
|
224
|
+
framesDir,
|
|
225
|
+
"frame_%06d.png",
|
|
226
|
+
join(root, "chunked.mp4"),
|
|
227
|
+
tinyEncodeOptions,
|
|
228
|
+
30,
|
|
229
|
+
undefined,
|
|
230
|
+
{ ffmpegEncodeTimeout: 1000 },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(calls).toHaveLength(1);
|
|
234
|
+
const proc = calls[0]!.proc;
|
|
235
|
+
vi.advanceTimersByTime(999);
|
|
236
|
+
expect(proc.kill).not.toHaveBeenCalled();
|
|
237
|
+
|
|
238
|
+
vi.advanceTimersByTime(1);
|
|
239
|
+
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
240
|
+
|
|
241
|
+
proc.stderr.emit("data", Buffer.from("chunk timeout\n"));
|
|
242
|
+
emitClose(proc, 143);
|
|
243
|
+
|
|
244
|
+
const result = await encodePromise;
|
|
245
|
+
expect(result.success).toBe(false);
|
|
246
|
+
expect(result.error).toContain("Chunk 0 encode failed");
|
|
247
|
+
expect(result.error).toContain("chunk timeout");
|
|
248
|
+
expect(result.error).toContain(encodeTimeoutMessage(1000));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("keeps non-timeout chunk failures unchanged", async () => {
|
|
252
|
+
vi.useFakeTimers();
|
|
253
|
+
const { spawn, calls } = createSpawnSpy();
|
|
254
|
+
vi.resetModules();
|
|
255
|
+
vi.doMock("child_process", () => ({ spawn }));
|
|
256
|
+
|
|
257
|
+
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
|
|
258
|
+
const { root, framesDir } = createFrameFixture();
|
|
259
|
+
|
|
260
|
+
const encodePromise = encodeFramesChunkedConcat(
|
|
261
|
+
framesDir,
|
|
262
|
+
"frame_%06d.png",
|
|
263
|
+
join(root, "chunked-failure.mp4"),
|
|
264
|
+
tinyEncodeOptions,
|
|
265
|
+
30,
|
|
266
|
+
undefined,
|
|
267
|
+
{ ffmpegEncodeTimeout: 1000 },
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(calls).toHaveLength(1);
|
|
271
|
+
const proc = calls[0]!.proc;
|
|
272
|
+
proc.stderr.emit("data", Buffer.from("chunk failed\n"));
|
|
273
|
+
emitClose(proc, 1);
|
|
274
|
+
|
|
275
|
+
const result = await encodePromise;
|
|
276
|
+
expect(result.success).toBe(false);
|
|
277
|
+
expect(result.error).toBe("Chunk 0 encode failed: chunk failed\n");
|
|
278
|
+
expect(result.error).not.toContain("ffmpegEncodeTimeout");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("kills concat ffmpeg when config timeout elapses", async () => {
|
|
282
|
+
vi.useFakeTimers();
|
|
283
|
+
const { spawn, calls } = createSpawnSpy();
|
|
284
|
+
vi.resetModules();
|
|
285
|
+
vi.doMock("child_process", () => ({ spawn }));
|
|
286
|
+
|
|
287
|
+
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
|
|
288
|
+
const { root, framesDir } = createFrameFixture();
|
|
289
|
+
|
|
290
|
+
const encodePromise = encodeFramesChunkedConcat(
|
|
291
|
+
framesDir,
|
|
292
|
+
"frame_%06d.png",
|
|
293
|
+
join(root, "concat-timeout.mp4"),
|
|
294
|
+
tinyEncodeOptions,
|
|
295
|
+
30,
|
|
296
|
+
undefined,
|
|
297
|
+
{ ffmpegEncodeTimeout: 1000 },
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
expect(calls).toHaveLength(1);
|
|
301
|
+
emitClose(calls[0]!.proc, 0);
|
|
302
|
+
await Promise.resolve();
|
|
303
|
+
|
|
304
|
+
expect(calls).toHaveLength(2);
|
|
305
|
+
const concatProc = calls[1]!.proc;
|
|
306
|
+
vi.advanceTimersByTime(999);
|
|
307
|
+
expect(concatProc.kill).not.toHaveBeenCalled();
|
|
308
|
+
|
|
309
|
+
vi.advanceTimersByTime(1);
|
|
310
|
+
expect(concatProc.kill).toHaveBeenCalledWith("SIGTERM");
|
|
311
|
+
|
|
312
|
+
concatProc.stderr.emit("data", Buffer.from("concat timeout\n"));
|
|
313
|
+
emitClose(concatProc, 143);
|
|
314
|
+
|
|
315
|
+
const result = await encodePromise;
|
|
316
|
+
expect(result.success).toBe(false);
|
|
317
|
+
expect(result.error).toContain("Chunk concat failed");
|
|
318
|
+
expect(result.error).toContain("concat timeout");
|
|
319
|
+
expect(result.error).toContain(encodeTimeoutMessage(1000));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("uses the default timeout for per-chunk encodes when config is omitted", async () => {
|
|
323
|
+
vi.useFakeTimers();
|
|
324
|
+
const { spawn, calls } = createSpawnSpy();
|
|
325
|
+
vi.resetModules();
|
|
326
|
+
vi.doMock("child_process", () => ({ spawn }));
|
|
327
|
+
|
|
328
|
+
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
|
|
329
|
+
const { root, framesDir } = createFrameFixture();
|
|
330
|
+
|
|
331
|
+
const encodePromise = encodeFramesChunkedConcat(
|
|
332
|
+
framesDir,
|
|
333
|
+
"frame_%06d.png",
|
|
334
|
+
join(root, "chunked-default.mp4"),
|
|
335
|
+
tinyEncodeOptions,
|
|
336
|
+
30,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
expect(calls).toHaveLength(1);
|
|
340
|
+
const chunkProc = calls[0]!.proc;
|
|
341
|
+
vi.advanceTimersByTime(599_999);
|
|
342
|
+
expect(chunkProc.kill).not.toHaveBeenCalled();
|
|
343
|
+
|
|
344
|
+
emitClose(chunkProc, 0);
|
|
345
|
+
await Promise.resolve();
|
|
346
|
+
|
|
347
|
+
expect(calls).toHaveLength(2);
|
|
348
|
+
const concatProc = calls[1]!.proc;
|
|
349
|
+
emitClose(concatProc, 0);
|
|
350
|
+
|
|
351
|
+
const result = await encodePromise;
|
|
352
|
+
expect(result.success).toBe(true);
|
|
353
|
+
expect(result.framesEncoded).toBe(2);
|
|
354
|
+
expect(result.fileSize).toBe(0);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
29
358
|
describe("getEncoderPreset", () => {
|
|
30
359
|
it("returns h264 with yuv420p for mp4 format", () => {
|
|
31
360
|
const preset = getEncoderPreset("standard", "mp4");
|
|
@@ -39,6 +39,11 @@ export interface EncoderPreset {
|
|
|
39
39
|
hdr?: { transfer: HdrTransfer };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function appendEncodeTimeoutMessage(error: string, timedOut: boolean, timeoutMs: number): string {
|
|
43
|
+
if (!timedOut) return error;
|
|
44
|
+
return `${error}\nFFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms)`;
|
|
45
|
+
}
|
|
46
|
+
|
|
42
47
|
/**
|
|
43
48
|
* Get encoder preset for a given quality and output format.
|
|
44
49
|
* WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
|
|
@@ -428,7 +433,9 @@ export async function encodeFramesFromDir(
|
|
|
428
433
|
}
|
|
429
434
|
|
|
430
435
|
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
|
436
|
+
let timedOut = false;
|
|
431
437
|
const timer = setTimeout(() => {
|
|
438
|
+
timedOut = true;
|
|
432
439
|
ffmpeg.kill("SIGTERM");
|
|
433
440
|
}, encodeTimeout);
|
|
434
441
|
|
|
@@ -440,7 +447,7 @@ export async function encodeFramesFromDir(
|
|
|
440
447
|
clearTimeout(timer);
|
|
441
448
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
442
449
|
const durationMs = Date.now() - startTime;
|
|
443
|
-
if (signal?.aborted) {
|
|
450
|
+
if (signal?.aborted && !timedOut) {
|
|
444
451
|
resolve({
|
|
445
452
|
success: false,
|
|
446
453
|
outputPath,
|
|
@@ -452,14 +459,18 @@ export async function encodeFramesFromDir(
|
|
|
452
459
|
return;
|
|
453
460
|
}
|
|
454
461
|
|
|
455
|
-
if (code !== 0) {
|
|
462
|
+
if (code !== 0 || timedOut) {
|
|
456
463
|
resolve({
|
|
457
464
|
success: false,
|
|
458
465
|
outputPath,
|
|
459
466
|
durationMs,
|
|
460
467
|
framesEncoded: 0,
|
|
461
468
|
fileSize: 0,
|
|
462
|
-
error:
|
|
469
|
+
error: appendEncodeTimeoutMessage(
|
|
470
|
+
formatFfmpegError(code, stderr),
|
|
471
|
+
timedOut,
|
|
472
|
+
encodeTimeout,
|
|
473
|
+
),
|
|
463
474
|
});
|
|
464
475
|
return;
|
|
465
476
|
}
|
|
@@ -477,7 +488,7 @@ export async function encodeFramesFromDir(
|
|
|
477
488
|
durationMs: Date.now() - startTime,
|
|
478
489
|
framesEncoded: 0,
|
|
479
490
|
fileSize: 0,
|
|
480
|
-
error: `[FFmpeg] ${err.message}`,
|
|
491
|
+
error: appendEncodeTimeoutMessage(`[FFmpeg] ${err.message}`, timedOut, encodeTimeout),
|
|
481
492
|
});
|
|
482
493
|
});
|
|
483
494
|
});
|
|
@@ -490,6 +501,7 @@ export async function encodeFramesChunkedConcat(
|
|
|
490
501
|
options: EncoderOptions,
|
|
491
502
|
chunkSizeFrames: number,
|
|
492
503
|
signal?: AbortSignal,
|
|
504
|
+
config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>,
|
|
493
505
|
): Promise<EncodeResult> {
|
|
494
506
|
const start = Date.now();
|
|
495
507
|
const files = readdirSync(framesDir)
|
|
@@ -548,15 +560,39 @@ export async function encodeFramesChunkedConcat(
|
|
|
548
560
|
const ffmpeg = spawn(getFfmpegBinary(), args);
|
|
549
561
|
trackChildProcess(ffmpeg);
|
|
550
562
|
let stderr = "";
|
|
563
|
+
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
|
564
|
+
let timedOut = false;
|
|
565
|
+
const timer = setTimeout(() => {
|
|
566
|
+
timedOut = true;
|
|
567
|
+
ffmpeg.kill("SIGTERM");
|
|
568
|
+
}, encodeTimeout);
|
|
551
569
|
ffmpeg.stderr.on("data", (d) => {
|
|
552
570
|
stderr += d.toString();
|
|
553
571
|
});
|
|
554
572
|
ffmpeg.on("close", (code) => {
|
|
555
|
-
|
|
556
|
-
|
|
573
|
+
clearTimeout(timer);
|
|
574
|
+
if (code === 0 && !timedOut) resolve({ success: true });
|
|
575
|
+
else {
|
|
576
|
+
resolve({
|
|
577
|
+
success: false,
|
|
578
|
+
error: appendEncodeTimeoutMessage(
|
|
579
|
+
`Chunk ${i} encode failed: ${stderr.slice(-400)}`,
|
|
580
|
+
timedOut,
|
|
581
|
+
encodeTimeout,
|
|
582
|
+
),
|
|
583
|
+
});
|
|
584
|
+
}
|
|
557
585
|
});
|
|
558
586
|
ffmpeg.on("error", (err) => {
|
|
559
|
-
|
|
587
|
+
clearTimeout(timer);
|
|
588
|
+
resolve({
|
|
589
|
+
success: false,
|
|
590
|
+
error: appendEncodeTimeoutMessage(
|
|
591
|
+
`Chunk ${i} encode error: ${err.message}`,
|
|
592
|
+
timedOut,
|
|
593
|
+
encodeTimeout,
|
|
594
|
+
),
|
|
595
|
+
});
|
|
560
596
|
});
|
|
561
597
|
});
|
|
562
598
|
if (!chunkResult.success) {
|
|
@@ -592,15 +628,39 @@ export async function encodeFramesChunkedConcat(
|
|
|
592
628
|
const ffmpeg = spawn(getFfmpegBinary(), concatArgs);
|
|
593
629
|
trackChildProcess(ffmpeg);
|
|
594
630
|
let stderr = "";
|
|
631
|
+
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
|
632
|
+
let timedOut = false;
|
|
633
|
+
const timer = setTimeout(() => {
|
|
634
|
+
timedOut = true;
|
|
635
|
+
ffmpeg.kill("SIGTERM");
|
|
636
|
+
}, encodeTimeout);
|
|
595
637
|
ffmpeg.stderr.on("data", (d) => {
|
|
596
638
|
stderr += d.toString();
|
|
597
639
|
});
|
|
598
640
|
ffmpeg.on("close", (code) => {
|
|
599
|
-
|
|
600
|
-
|
|
641
|
+
clearTimeout(timer);
|
|
642
|
+
if (code === 0 && !timedOut) resolve({ success: true });
|
|
643
|
+
else {
|
|
644
|
+
resolve({
|
|
645
|
+
success: false,
|
|
646
|
+
error: appendEncodeTimeoutMessage(
|
|
647
|
+
`Chunk concat failed: ${stderr.slice(-400)}`,
|
|
648
|
+
timedOut,
|
|
649
|
+
encodeTimeout,
|
|
650
|
+
),
|
|
651
|
+
});
|
|
652
|
+
}
|
|
601
653
|
});
|
|
602
654
|
ffmpeg.on("error", (err) => {
|
|
603
|
-
|
|
655
|
+
clearTimeout(timer);
|
|
656
|
+
resolve({
|
|
657
|
+
success: false,
|
|
658
|
+
error: appendEncodeTimeoutMessage(
|
|
659
|
+
`Chunk concat error: ${err.message}`,
|
|
660
|
+
timedOut,
|
|
661
|
+
encodeTimeout,
|
|
662
|
+
),
|
|
663
|
+
});
|
|
604
664
|
});
|
|
605
665
|
});
|
|
606
666
|
|