@hyperframes/engine 0.6.46 → 0.6.47
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.map +1 -1
- package/dist/services/chunkEncoder.js +11 -2
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/parallelCoordinator.d.ts.map +1 -1
- package/dist/services/parallelCoordinator.js +43 -43
- package/dist/services/parallelCoordinator.js.map +1 -1
- package/dist/services/screenshotService.d.ts +9 -1
- package/dist/services/screenshotService.d.ts.map +1 -1
- package/dist/services/screenshotService.js +95 -7
- package/dist/services/screenshotService.js.map +1 -1
- package/dist/services/streamingEncoder.d.ts.map +1 -1
- package/dist/services/streamingEncoder.js +12 -3
- package/dist/services/streamingEncoder.js.map +1 -1
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +10 -2
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/utils/gpuEncoder.d.ts +6 -3
- package/dist/utils/gpuEncoder.d.ts.map +1 -1
- package/dist/utils/gpuEncoder.js +123 -12
- package/dist/utils/gpuEncoder.js.map +1 -1
- package/package.json +2 -2
- package/src/services/chunkEncoder.test.ts +24 -2
- package/src/services/chunkEncoder.ts +9 -2
- package/src/services/parallelCoordinator.ts +58 -42
- package/src/services/screenshotService.test.ts +317 -0
- package/src/services/screenshotService.ts +99 -8
- package/src/services/streamingEncoder.test.ts +20 -0
- package/src/services/streamingEncoder.ts +10 -3
- package/src/services/videoFrameInjector.test.ts +111 -2
- package/src/services/videoFrameInjector.ts +14 -4
- package/src/utils/gpuEncoder.test.ts +68 -3
- package/src/utils/gpuEncoder.ts +155 -8
|
@@ -231,14 +231,21 @@ export function buildStreamingArgs(
|
|
|
231
231
|
if (bitrate) args.push("-b:v", bitrate);
|
|
232
232
|
else args.push("-global_quality", String(quality));
|
|
233
233
|
break;
|
|
234
|
+
case "amf":
|
|
235
|
+
if (bitrate) args.push("-b:v", bitrate);
|
|
236
|
+
else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality));
|
|
237
|
+
break;
|
|
234
238
|
}
|
|
235
239
|
|
|
236
|
-
// Mirror SW branch: GPU h264 paths emit B-frames by default (nvenc,
|
|
237
|
-
// vaapi) and produce the same negative-DTS freeze for downstream players.
|
|
240
|
+
// Mirror SW branch: GPU h264 paths emit B-frames by default (nvenc, amf,
|
|
241
|
+
// qsv, vaapi) and produce the same negative-DTS freeze for downstream players.
|
|
238
242
|
// See chunkEncoder.buildEncoderArgs for the full explanation.
|
|
239
243
|
if (
|
|
240
244
|
codec === "h264" &&
|
|
241
|
-
(gpuEncoder === "nvenc" ||
|
|
245
|
+
(gpuEncoder === "nvenc" ||
|
|
246
|
+
gpuEncoder === "qsv" ||
|
|
247
|
+
gpuEncoder === "vaapi" ||
|
|
248
|
+
gpuEncoder === "amf")
|
|
242
249
|
) {
|
|
243
250
|
args.push("-bf", "0");
|
|
244
251
|
if (gpuEncoder === "qsv") {
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
// @vitest-environment node
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
3
3
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { type Page } from "puppeteer-core";
|
|
7
|
+
|
|
8
|
+
// Hoist mocks before importing the module under test so the mock factory wins.
|
|
9
|
+
// The cache-hygiene block exercises createVideoFrameInjector against stubbed
|
|
10
|
+
// page-side primitives so we can assert on Node-side state (cache poisoning)
|
|
11
|
+
// without standing up a real browser.
|
|
12
|
+
const { injectVideoFramesBatchMock, syncVideoFrameVisibilityMock } = vi.hoisted(() => ({
|
|
13
|
+
injectVideoFramesBatchMock: vi.fn<
|
|
14
|
+
(page: Page, updates: Array<{ videoId: string; dataUri: string }>) => Promise<string[]>
|
|
15
|
+
>(async (_page, updates) => updates.map((u) => u.videoId)),
|
|
16
|
+
syncVideoFrameVisibilityMock: vi.fn<(page: Page, ids: string[]) => Promise<void>>(
|
|
17
|
+
async () => undefined,
|
|
18
|
+
),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("./screenshotService.js", () => ({
|
|
22
|
+
injectVideoFramesBatch: injectVideoFramesBatchMock,
|
|
23
|
+
syncVideoFrameVisibility: syncVideoFrameVisibilityMock,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { __testing, createVideoFrameInjector } from "./videoFrameInjector.js";
|
|
27
|
+
import { type FrameLookupTable } from "./videoFrameExtractor.js";
|
|
7
28
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
8
29
|
|
|
9
30
|
const { createFrameSourceCache } = __testing;
|
|
@@ -143,3 +164,91 @@ describe("frame source cache eviction", () => {
|
|
|
143
164
|
expect(cache.stats()).toMatchObject({ ...SHARED_STATS, entries: 0, bytes: 0 });
|
|
144
165
|
});
|
|
145
166
|
});
|
|
167
|
+
|
|
168
|
+
describe("createVideoFrameInjector cache hygiene against page-side skips", () => {
|
|
169
|
+
// Build a minimal FrameLookupTable stand-in that returns one fixed payload
|
|
170
|
+
// for every time so we can drive the hook deterministically. The real
|
|
171
|
+
// table is exercised exhaustively in videoFrameExtractor.test.ts.
|
|
172
|
+
function fakeTable(payload: { videoId: string; framePath: string; frameIndex: number }) {
|
|
173
|
+
return {
|
|
174
|
+
getActiveFramePayloads: () =>
|
|
175
|
+
new Map([
|
|
176
|
+
[payload.videoId, { framePath: payload.framePath, frameIndex: payload.frameIndex }],
|
|
177
|
+
]),
|
|
178
|
+
} as unknown as FrameLookupTable;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Bypass the on-disk frame cache by handing back a synthetic data URI.
|
|
182
|
+
function inlineResolver(framePath: string): string {
|
|
183
|
+
return `data:image/png;base64,fake-${framePath}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
injectVideoFramesBatchMock.mockReset();
|
|
188
|
+
syncVideoFrameVisibilityMock.mockReset();
|
|
189
|
+
syncVideoFrameVisibilityMock.mockResolvedValue(undefined);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("does not poison the lastInjected cache when the page reports zero ids injected", async () => {
|
|
193
|
+
// Regression for the agentic-finecut scenario after PR #1028's ancestor
|
|
194
|
+
// skip: when injectVideoFramesBatch silently drops a video (its sub-comp
|
|
195
|
+
// host is hidden), the caller used to record `lastInjectedFrame[v] = N`
|
|
196
|
+
// anyway. On the next frame, if the source frameIndex is unchanged
|
|
197
|
+
// (low-fps source, multiple output frames per source frame, or
|
|
198
|
+
// non-frame-aligned host start), the cache short-circuits the second
|
|
199
|
+
// call and the host's first visible frame paints blank because the
|
|
200
|
+
// replacement <img> was never created.
|
|
201
|
+
//
|
|
202
|
+
// Pin the contract: when the page returns `[]` (no ids actually
|
|
203
|
+
// injected), the cache must not record those frameIndexes, so a follow-
|
|
204
|
+
// up call at the same frameIndex still issues an inject.
|
|
205
|
+
const fakePage = {} as Page;
|
|
206
|
+
const hook = createVideoFrameInjector(
|
|
207
|
+
fakeTable({ videoId: "pip", framePath: "/p", frameIndex: 5 }),
|
|
208
|
+
{
|
|
209
|
+
frameSrcResolver: inlineResolver,
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
expect(hook).not.toBeNull();
|
|
213
|
+
|
|
214
|
+
// First call: simulate the ancestor-hidden skip — page-side reports it
|
|
215
|
+
// injected nothing.
|
|
216
|
+
injectVideoFramesBatchMock.mockResolvedValueOnce([]);
|
|
217
|
+
await hook!(fakePage, 0);
|
|
218
|
+
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1);
|
|
219
|
+
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(fakePage, [
|
|
220
|
+
{ videoId: "pip", dataUri: "data:image/png;base64,fake-/p" },
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
// Second call: same frameIndex, but the previous call did not really
|
|
224
|
+
// paint. The cache must NOT short-circuit; the inject must run again.
|
|
225
|
+
injectVideoFramesBatchMock.mockResolvedValueOnce(["pip"]);
|
|
226
|
+
await hook!(fakePage, 0);
|
|
227
|
+
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(2);
|
|
228
|
+
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(fakePage, [
|
|
229
|
+
{ videoId: "pip", dataUri: "data:image/png;base64,fake-/p" },
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does cache normally when the page reports the id as injected", async () => {
|
|
234
|
+
// Counter-test: when injection succeeds for a videoId, the cache must
|
|
235
|
+
// record it and a second call at the same frameIndex must short-circuit.
|
|
236
|
+
// This pins the happy path so a future refactor can't trade the skip
|
|
237
|
+
// bug for a never-cache regression.
|
|
238
|
+
const fakePage = {} as Page;
|
|
239
|
+
const hook = createVideoFrameInjector(
|
|
240
|
+
fakeTable({ videoId: "pip", framePath: "/p", frameIndex: 5 }),
|
|
241
|
+
{
|
|
242
|
+
frameSrcResolver: inlineResolver,
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
injectVideoFramesBatchMock.mockResolvedValueOnce(["pip"]);
|
|
247
|
+
await hook!(fakePage, 0);
|
|
248
|
+
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1);
|
|
249
|
+
|
|
250
|
+
await hook!(fakePage, 0);
|
|
251
|
+
// Cache hit — no second inject for the same frameIndex.
|
|
252
|
+
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -197,12 +197,22 @@ export function createVideoFrameInjector(
|
|
|
197
197
|
|
|
198
198
|
await syncVideoFrameVisibility(page, Array.from(activeIds));
|
|
199
199
|
if (updates.length > 0) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
// Only record cache entries for videos the page actually painted.
|
|
201
|
+
// injectVideoFramesBatch skips any video whose visual ancestor is
|
|
202
|
+
// hidden (sub-comp host out-of-window) and returns the subset of ids
|
|
203
|
+
// it really wrote — recording the rest would short-circuit the next
|
|
204
|
+
// call at the same frameIndex and leave the host's first visible
|
|
205
|
+
// frame blank.
|
|
206
|
+
const injectedIds = new Set(
|
|
207
|
+
await injectVideoFramesBatch(
|
|
208
|
+
page,
|
|
209
|
+
updates.map((u) => ({ videoId: u.videoId, dataUri: u.dataUri })),
|
|
210
|
+
),
|
|
203
211
|
);
|
|
204
212
|
for (const update of updates) {
|
|
205
|
-
|
|
213
|
+
if (injectedIds.has(update.videoId)) {
|
|
214
|
+
lastInjectedFrameByVideo.set(update.videoId, update.frameIndex);
|
|
215
|
+
}
|
|
206
216
|
}
|
|
207
217
|
}
|
|
208
218
|
};
|
|
@@ -1,6 +1,71 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getCompiledGpuEncoders,
|
|
5
|
+
getGpuEncoderName,
|
|
6
|
+
mapPresetForGpuEncoder,
|
|
7
|
+
selectUsableGpuEncoder,
|
|
8
|
+
} from "./gpuEncoder.js";
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("getCompiledGpuEncoders", () => {
|
|
15
|
+
it("recognizes AMD AMF in FFmpeg's encoder list", () => {
|
|
16
|
+
expect(
|
|
17
|
+
getCompiledGpuEncoders(`
|
|
18
|
+
V....D h264_nvenc NVIDIA NVENC H.264 encoder
|
|
19
|
+
V....D h264_amf AMD AMF H.264 Encoder
|
|
20
|
+
V....D h264_qsv H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (Intel Quick Sync Video)
|
|
21
|
+
`),
|
|
22
|
+
).toEqual(["nvenc", "qsv", "amf"]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("selectUsableGpuEncoder", () => {
|
|
27
|
+
it("runs probe checks concurrently while preserving candidate priority", async () => {
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
const started: string[] = [];
|
|
30
|
+
const usable = selectUsableGpuEncoder(["nvenc", "amf"], async (encoder) => {
|
|
31
|
+
started.push(encoder);
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, encoder === "nvenc" ? 50 : 1));
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
37
|
+
expect(started).toEqual(["nvenc", "amf"]);
|
|
38
|
+
|
|
39
|
+
await vi.advanceTimersByTimeAsync(49);
|
|
40
|
+
expect(await usable).toBe("nvenc");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("falls through from compiled-but-unusable NVENC to usable AMD AMF", async () => {
|
|
44
|
+
const usable = await selectUsableGpuEncoder(["nvenc", "amf"], async (encoder) => {
|
|
45
|
+
return encoder === "amf";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(usable).toBe("amf");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("treats rejected probe checks as unusable", async () => {
|
|
52
|
+
const usable = await selectUsableGpuEncoder(["nvenc", "amf"], async (encoder) => {
|
|
53
|
+
if (encoder === "nvenc") {
|
|
54
|
+
throw new Error("driver probe failed");
|
|
55
|
+
}
|
|
56
|
+
return encoder === "amf";
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(usable).toBe("amf");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("getGpuEncoderName", () => {
|
|
64
|
+
it("maps AMD AMF to FFmpeg's h264 and hevc encoder names", () => {
|
|
65
|
+
expect(getGpuEncoderName("amf", "h264")).toBe("h264_amf");
|
|
66
|
+
expect(getGpuEncoderName("amf", "h265")).toBe("hevc_amf");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
4
69
|
|
|
5
70
|
describe("mapPresetForGpuEncoder", () => {
|
|
6
71
|
describe("nvenc", () => {
|
|
@@ -49,7 +114,7 @@ describe("mapPresetForGpuEncoder", () => {
|
|
|
49
114
|
});
|
|
50
115
|
|
|
51
116
|
describe("other encoders", () => {
|
|
52
|
-
it.each(["videotoolbox", "vaapi"] as const)(
|
|
117
|
+
it.each(["videotoolbox", "vaapi", "amf"] as const)(
|
|
53
118
|
"passes preset through unchanged for %s",
|
|
54
119
|
(encoder) => {
|
|
55
120
|
expect(mapPresetForGpuEncoder(encoder, "medium")).toBe("medium");
|
package/src/utils/gpuEncoder.ts
CHANGED
|
@@ -7,7 +7,55 @@
|
|
|
7
7
|
|
|
8
8
|
import { spawn } from "child_process";
|
|
9
9
|
|
|
10
|
-
export type
|
|
10
|
+
export type ConcreteGpuEncoder = "nvenc" | "videotoolbox" | "vaapi" | "qsv" | "amf";
|
|
11
|
+
export type GpuEncoder = ConcreteGpuEncoder | null;
|
|
12
|
+
|
|
13
|
+
const GPU_ENCODER_CANDIDATES: ConcreteGpuEncoder[] = [
|
|
14
|
+
"nvenc",
|
|
15
|
+
"videotoolbox",
|
|
16
|
+
"vaapi",
|
|
17
|
+
"qsv",
|
|
18
|
+
"amf",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const H264_ENCODER_BY_GPU: Record<ConcreteGpuEncoder, string> = {
|
|
22
|
+
nvenc: "h264_nvenc",
|
|
23
|
+
videotoolbox: "h264_videotoolbox",
|
|
24
|
+
vaapi: "h264_vaapi",
|
|
25
|
+
qsv: "h264_qsv",
|
|
26
|
+
amf: "h264_amf",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const GPU_PROBE_TIMEOUT_MS = 2000;
|
|
30
|
+
const GPU_PROBE_KILL_GRACE_MS = 1000;
|
|
31
|
+
|
|
32
|
+
export function getCompiledGpuEncoders(ffmpegEncodersStdout: string): ConcreteGpuEncoder[] {
|
|
33
|
+
return GPU_ENCODER_CANDIDATES.filter((encoder) =>
|
|
34
|
+
ffmpegEncodersStdout.includes(H264_ENCODER_BY_GPU[encoder]),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function selectUsableGpuEncoder(
|
|
39
|
+
candidates: readonly ConcreteGpuEncoder[],
|
|
40
|
+
isUsable: (encoder: ConcreteGpuEncoder) => Promise<boolean>,
|
|
41
|
+
): Promise<GpuEncoder> {
|
|
42
|
+
const results = await Promise.all(
|
|
43
|
+
candidates.map(async (encoder) => {
|
|
44
|
+
try {
|
|
45
|
+
return { encoder, usable: await isUsable(encoder) };
|
|
46
|
+
} catch {
|
|
47
|
+
return { encoder, usable: false };
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
for (const result of results) {
|
|
53
|
+
if (result.usable) {
|
|
54
|
+
return result.encoder;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
11
59
|
|
|
12
60
|
export async function detectGpuEncoder(): Promise<GpuEncoder> {
|
|
13
61
|
return new Promise((resolve) => {
|
|
@@ -21,11 +69,10 @@ export async function detectGpuEncoder(): Promise<GpuEncoder> {
|
|
|
21
69
|
});
|
|
22
70
|
|
|
23
71
|
ffmpeg.on("close", () => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
else resolve(null);
|
|
72
|
+
const candidates = getCompiledGpuEncoders(stdout);
|
|
73
|
+
void selectUsableGpuEncoder(candidates, canUseGpuEncoder)
|
|
74
|
+
.then(resolve)
|
|
75
|
+
.catch(() => resolve(null));
|
|
29
76
|
});
|
|
30
77
|
|
|
31
78
|
ffmpeg.on("error", () => resolve(null));
|
|
@@ -52,11 +99,111 @@ export function getGpuEncoderName(encoder: GpuEncoder, codec: "h264" | "h265"):
|
|
|
52
99
|
return codec === "h264" ? "h264_vaapi" : "hevc_vaapi";
|
|
53
100
|
case "qsv":
|
|
54
101
|
return codec === "h264" ? "h264_qsv" : "hevc_qsv";
|
|
102
|
+
case "amf":
|
|
103
|
+
return codec === "h264" ? "h264_amf" : "hevc_amf";
|
|
55
104
|
default:
|
|
56
105
|
return codec === "h264" ? "libx264" : "libx265";
|
|
57
106
|
}
|
|
58
107
|
}
|
|
59
108
|
|
|
109
|
+
function getProbeArgs(encoder: ConcreteGpuEncoder): string[] {
|
|
110
|
+
const args = [
|
|
111
|
+
"-hide_banner",
|
|
112
|
+
"-loglevel",
|
|
113
|
+
"error",
|
|
114
|
+
"-f",
|
|
115
|
+
"lavfi",
|
|
116
|
+
"-i",
|
|
117
|
+
"color=size=16x16:rate=1:duration=1",
|
|
118
|
+
"-frames:v",
|
|
119
|
+
"1",
|
|
120
|
+
"-an",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
if (encoder === "vaapi") {
|
|
124
|
+
args.push("-vaapi_device", "/dev/dri/renderD128", "-vf", "format=nv12,hwupload");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
args.push("-c:v", getGpuEncoderName(encoder, "h264"));
|
|
128
|
+
|
|
129
|
+
if (encoder === "amf") {
|
|
130
|
+
args.push("-rc", "cqp", "-qp_i", "28", "-qp_p", "28");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
args.push("-f", "null", "-");
|
|
134
|
+
return args;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function canUseGpuEncoder(encoder: ConcreteGpuEncoder): Promise<boolean> {
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
let settled = false;
|
|
140
|
+
let timedOut = false;
|
|
141
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
142
|
+
let stderr = "";
|
|
143
|
+
const finish = (usable: boolean) => {
|
|
144
|
+
if (settled) return;
|
|
145
|
+
settled = true;
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
if (killTimer) clearTimeout(killTimer);
|
|
148
|
+
resolve(usable);
|
|
149
|
+
};
|
|
150
|
+
const ffmpeg = spawn("ffmpeg", getProbeArgs(encoder), {
|
|
151
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
ffmpeg.stderr?.on("data", (data) => {
|
|
155
|
+
stderr += data.toString();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
timedOut = true;
|
|
160
|
+
ffmpeg.kill("SIGTERM");
|
|
161
|
+
killTimer = setTimeout(() => {
|
|
162
|
+
ffmpeg.kill("SIGKILL");
|
|
163
|
+
finish(false);
|
|
164
|
+
}, GPU_PROBE_KILL_GRACE_MS);
|
|
165
|
+
}, GPU_PROBE_TIMEOUT_MS);
|
|
166
|
+
|
|
167
|
+
ffmpeg.on("close", (code, signal) => {
|
|
168
|
+
const usable = code === 0;
|
|
169
|
+
logGpuProbeFailure(encoder, { code, signal, stderr, timedOut });
|
|
170
|
+
finish(usable);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
ffmpeg.on("error", (error) => {
|
|
174
|
+
logGpuProbeFailure(encoder, { error, timedOut });
|
|
175
|
+
finish(false);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function logGpuProbeFailure(
|
|
181
|
+
encoder: ConcreteGpuEncoder,
|
|
182
|
+
result: {
|
|
183
|
+
code?: number | null;
|
|
184
|
+
error?: Error;
|
|
185
|
+
signal?: NodeJS.Signals | null;
|
|
186
|
+
stderr?: string;
|
|
187
|
+
timedOut?: boolean;
|
|
188
|
+
},
|
|
189
|
+
): void {
|
|
190
|
+
if (!isGpuProbeDebugEnabled()) return;
|
|
191
|
+
if (result.code === 0 && !result.error && !result.timedOut) return;
|
|
192
|
+
|
|
193
|
+
const reason = result.error
|
|
194
|
+
? result.error.message
|
|
195
|
+
: result.timedOut
|
|
196
|
+
? `timed out after ${GPU_PROBE_TIMEOUT_MS}ms`
|
|
197
|
+
: `exit=${String(result.code)} signal=${String(result.signal ?? "")}`;
|
|
198
|
+
const stderr = result.stderr?.trim();
|
|
199
|
+
console.warn(`[gpuEncoder] ${encoder} probe failed: ${reason}${stderr ? `\n${stderr}` : ""}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isGpuProbeDebugEnabled(): boolean {
|
|
203
|
+
const value = process.env.HYPERFRAMES_DEBUG_GPU_PROBE;
|
|
204
|
+
return value === "1" || value === "true";
|
|
205
|
+
}
|
|
206
|
+
|
|
60
207
|
// libx264 preset names (ultrafast/superfast/.../placebo) mapped to the
|
|
61
208
|
// equivalent NVENC p1..p7 preset. NVENC rejects libx264 names with
|
|
62
209
|
// AVERROR(EINVAL) ("Error applying encoder options: Invalid argument"),
|
|
@@ -93,8 +240,8 @@ const QSV_PRESET_MAP: Record<string, string> = {
|
|
|
93
240
|
* through unchanged. Unknown values fall back to `p4` (medium).
|
|
94
241
|
* - `qsv`: `ultrafast`/`superfast`/`placebo` → nearest supported name;
|
|
95
242
|
* everything else passes through.
|
|
96
|
-
* - `videotoolbox`, `vaapi`, `null`: no remap (they either ignore
|
|
97
|
-
* entirely or accept the libx264 vocabulary).
|
|
243
|
+
* - `videotoolbox`, `vaapi`, `amf`, `null`: no remap (they either ignore
|
|
244
|
+
* `-preset` entirely or accept the libx264 vocabulary).
|
|
98
245
|
*/
|
|
99
246
|
export function mapPresetForGpuEncoder(encoder: GpuEncoder, preset: string): string {
|
|
100
247
|
switch (encoder) {
|