@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.
Files changed (32) hide show
  1. package/dist/services/chunkEncoder.d.ts.map +1 -1
  2. package/dist/services/chunkEncoder.js +11 -2
  3. package/dist/services/chunkEncoder.js.map +1 -1
  4. package/dist/services/parallelCoordinator.d.ts.map +1 -1
  5. package/dist/services/parallelCoordinator.js +43 -43
  6. package/dist/services/parallelCoordinator.js.map +1 -1
  7. package/dist/services/screenshotService.d.ts +9 -1
  8. package/dist/services/screenshotService.d.ts.map +1 -1
  9. package/dist/services/screenshotService.js +95 -7
  10. package/dist/services/screenshotService.js.map +1 -1
  11. package/dist/services/streamingEncoder.d.ts.map +1 -1
  12. package/dist/services/streamingEncoder.js +12 -3
  13. package/dist/services/streamingEncoder.js.map +1 -1
  14. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  15. package/dist/services/videoFrameInjector.js +10 -2
  16. package/dist/services/videoFrameInjector.js.map +1 -1
  17. package/dist/utils/gpuEncoder.d.ts +6 -3
  18. package/dist/utils/gpuEncoder.d.ts.map +1 -1
  19. package/dist/utils/gpuEncoder.js +123 -12
  20. package/dist/utils/gpuEncoder.js.map +1 -1
  21. package/package.json +2 -2
  22. package/src/services/chunkEncoder.test.ts +24 -2
  23. package/src/services/chunkEncoder.ts +9 -2
  24. package/src/services/parallelCoordinator.ts +58 -42
  25. package/src/services/screenshotService.test.ts +317 -0
  26. package/src/services/screenshotService.ts +99 -8
  27. package/src/services/streamingEncoder.test.ts +20 -0
  28. package/src/services/streamingEncoder.ts +10 -3
  29. package/src/services/videoFrameInjector.test.ts +111 -2
  30. package/src/services/videoFrameInjector.ts +14 -4
  31. package/src/utils/gpuEncoder.test.ts +68 -3
  32. 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, qsv,
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" || gpuEncoder === "qsv" || gpuEncoder === "vaapi")
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 { __testing } from "./videoFrameInjector.js";
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
- await injectVideoFramesBatch(
201
- page,
202
- updates.map((u) => ({ videoId: u.videoId, dataUri: u.dataUri })),
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
- lastInjectedFrameByVideo.set(update.videoId, update.frameIndex);
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 { mapPresetForGpuEncoder } from "./gpuEncoder.js";
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");
@@ -7,7 +7,55 @@
7
7
 
8
8
  import { spawn } from "child_process";
9
9
 
10
- export type GpuEncoder = "nvenc" | "videotoolbox" | "vaapi" | "qsv" | null;
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
- if (stdout.includes("h264_nvenc")) resolve("nvenc");
25
- else if (stdout.includes("h264_videotoolbox")) resolve("videotoolbox");
26
- else if (stdout.includes("h264_vaapi")) resolve("vaapi");
27
- else if (stdout.includes("h264_qsv")) resolve("qsv");
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 `-preset`
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) {