@hyperframes/engine 0.1.0
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/config.d.ts +62 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +92 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +77 -0
- package/dist/config.test.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/services/audioMixer.d.ts +11 -0
- package/dist/services/audioMixer.d.ts.map +1 -0
- package/dist/services/audioMixer.js +306 -0
- package/dist/services/audioMixer.js.map +1 -0
- package/dist/services/audioMixer.types.d.ts +27 -0
- package/dist/services/audioMixer.types.d.ts.map +1 -0
- package/dist/services/audioMixer.types.js +2 -0
- package/dist/services/audioMixer.types.js.map +1 -0
- package/dist/services/browserManager.d.ts +29 -0
- package/dist/services/browserManager.d.ts.map +1 -0
- package/dist/services/browserManager.js +175 -0
- package/dist/services/browserManager.js.map +1 -0
- package/dist/services/chunkEncoder.d.ts +32 -0
- package/dist/services/chunkEncoder.d.ts.map +1 -0
- package/dist/services/chunkEncoder.js +347 -0
- package/dist/services/chunkEncoder.js.map +1 -0
- package/dist/services/chunkEncoder.test.d.ts +2 -0
- package/dist/services/chunkEncoder.test.d.ts.map +1 -0
- package/dist/services/chunkEncoder.test.js +24 -0
- package/dist/services/chunkEncoder.test.js.map +1 -0
- package/dist/services/chunkEncoder.types.d.ts +26 -0
- package/dist/services/chunkEncoder.types.d.ts.map +1 -0
- package/dist/services/chunkEncoder.types.js +2 -0
- package/dist/services/chunkEncoder.types.js.map +1 -0
- package/dist/services/fileServer.d.ts +25 -0
- package/dist/services/fileServer.d.ts.map +1 -0
- package/dist/services/fileServer.js +138 -0
- package/dist/services/fileServer.js.map +1 -0
- package/dist/services/frameCapture.d.ts +52 -0
- package/dist/services/frameCapture.d.ts.map +1 -0
- package/dist/services/frameCapture.js +317 -0
- package/dist/services/frameCapture.js.map +1 -0
- package/dist/services/parallelCoordinator.d.ts +40 -0
- package/dist/services/parallelCoordinator.d.ts.map +1 -0
- package/dist/services/parallelCoordinator.js +192 -0
- package/dist/services/parallelCoordinator.js.map +1 -0
- package/dist/services/parallelCoordinator.test.d.ts +2 -0
- package/dist/services/parallelCoordinator.test.d.ts.map +1 -0
- package/dist/services/parallelCoordinator.test.js +38 -0
- package/dist/services/parallelCoordinator.test.js.map +1 -0
- package/dist/services/screenshotService.d.ts +28 -0
- package/dist/services/screenshotService.d.ts.map +1 -0
- package/dist/services/screenshotService.js +190 -0
- package/dist/services/screenshotService.js.map +1 -0
- package/dist/services/streamingEncoder.d.ts +49 -0
- package/dist/services/streamingEncoder.d.ts.map +1 -0
- package/dist/services/streamingEncoder.js +227 -0
- package/dist/services/streamingEncoder.js.map +1 -0
- package/dist/services/videoFrameExtractor.d.ts +65 -0
- package/dist/services/videoFrameExtractor.d.ts.map +1 -0
- package/dist/services/videoFrameExtractor.js +302 -0
- package/dist/services/videoFrameExtractor.js.map +1 -0
- package/dist/services/videoFrameInjector.d.ts +17 -0
- package/dist/services/videoFrameInjector.d.ts.map +1 -0
- package/dist/services/videoFrameInjector.js +92 -0
- package/dist/services/videoFrameInjector.js.map +1 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/ffprobe.d.ts +18 -0
- package/dist/utils/ffprobe.d.ts.map +1 -0
- package/dist/utils/ffprobe.js +137 -0
- package/dist/utils/ffprobe.js.map +1 -0
- package/dist/utils/gpuEncoder.d.ts +11 -0
- package/dist/utils/gpuEncoder.d.ts.map +1 -0
- package/dist/utils/gpuEncoder.js +55 -0
- package/dist/utils/gpuEncoder.js.map +1 -0
- package/dist/utils/parityContract.d.ts +3 -0
- package/dist/utils/parityContract.d.ts.map +1 -0
- package/dist/utils/parityContract.js +35 -0
- package/dist/utils/parityContract.js.map +1 -0
- package/dist/utils/runFfmpeg.d.ts +19 -0
- package/dist/utils/runFfmpeg.d.ts.map +1 -0
- package/dist/utils/runFfmpeg.js +62 -0
- package/dist/utils/runFfmpeg.js.map +1 -0
- package/dist/utils/urlDownloader.d.ts +3 -0
- package/dist/utils/urlDownloader.d.ts.map +1 -0
- package/dist/utils/urlDownloader.js +68 -0
- package/dist/utils/urlDownloader.js.map +1 -0
- package/package.json +30 -0
- package/src/cdp-headless-experimental.d.ts +54 -0
- package/src/config.test.ts +95 -0
- package/src/config.ts +176 -0
- package/src/index.ts +154 -0
- package/src/services/audioMixer.ts +392 -0
- package/src/services/audioMixer.types.ts +28 -0
- package/src/services/browserManager.ts +219 -0
- package/src/services/chunkEncoder.test.ts +27 -0
- package/src/services/chunkEncoder.ts +399 -0
- package/src/services/chunkEncoder.types.ts +27 -0
- package/src/services/fileServer.ts +175 -0
- package/src/services/frameCapture.ts +452 -0
- package/src/services/parallelCoordinator.test.ts +44 -0
- package/src/services/parallelCoordinator.ts +293 -0
- package/src/services/screenshotService.ts +222 -0
- package/src/services/streamingEncoder.ts +308 -0
- package/src/services/videoFrameExtractor.ts +395 -0
- package/src/services/videoFrameInjector.ts +113 -0
- package/src/types.ts +91 -0
- package/src/utils/ffprobe.ts +192 -0
- package/src/utils/gpuEncoder.ts +58 -0
- package/src/utils/parityContract.ts +35 -0
- package/src/utils/runFfmpeg.ts +81 -0
- package/src/utils/urlDownloader.ts +78 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame Capture Service
|
|
3
|
+
*
|
|
4
|
+
* Uses Puppeteer to capture frames from any web page implementing the
|
|
5
|
+
* window.__hf seek protocol. Navigates to a file server URL, waits for
|
|
6
|
+
* the page to expose window.__hf, then captures frames deterministically
|
|
7
|
+
* via Chrome's BeginFrame API or Page.captureScreenshot fallback.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type Browser, type Page, type Viewport, type ConsoleMessage } from "puppeteer-core";
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { quantizeTimeToFrame } from "@hyperframes/core";
|
|
14
|
+
|
|
15
|
+
// ── Extracted modules ───────────────────────────────────────────────────────
|
|
16
|
+
import {
|
|
17
|
+
acquireBrowser,
|
|
18
|
+
releaseBrowser,
|
|
19
|
+
buildChromeArgs,
|
|
20
|
+
resolveHeadlessShellPath,
|
|
21
|
+
type CaptureMode,
|
|
22
|
+
} from "./browserManager.js";
|
|
23
|
+
import { beginFrameCapture, getCdpSession, pageScreenshotCapture } from "./screenshotService.js";
|
|
24
|
+
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
|
25
|
+
import type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary } from "../types.js";
|
|
26
|
+
|
|
27
|
+
export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary };
|
|
28
|
+
|
|
29
|
+
/** Called after seeking, before screenshot. Use for video frame injection or other pre-capture work. */
|
|
30
|
+
export type BeforeCaptureHook = (page: Page, time: number) => Promise<void>;
|
|
31
|
+
|
|
32
|
+
export interface CaptureSession {
|
|
33
|
+
browser: Browser;
|
|
34
|
+
page: Page;
|
|
35
|
+
options: CaptureOptions;
|
|
36
|
+
serverUrl: string;
|
|
37
|
+
outputDir: string;
|
|
38
|
+
onBeforeCapture: BeforeCaptureHook | null;
|
|
39
|
+
isInitialized: boolean;
|
|
40
|
+
browserConsoleBuffer: string[];
|
|
41
|
+
capturePerf: {
|
|
42
|
+
frames: number;
|
|
43
|
+
seekMs: number;
|
|
44
|
+
beforeCaptureMs: number;
|
|
45
|
+
screenshotMs: number;
|
|
46
|
+
totalMs: number;
|
|
47
|
+
};
|
|
48
|
+
captureMode: CaptureMode;
|
|
49
|
+
// BeginFrame state
|
|
50
|
+
beginFrameTimeTicks: number;
|
|
51
|
+
beginFrameIntervalMs: number;
|
|
52
|
+
beginFrameHasDamageCount: number;
|
|
53
|
+
beginFrameNoDamageCount: number;
|
|
54
|
+
/** Optional producer config — when set, overrides module-level env var constants. */
|
|
55
|
+
config?: Partial<EngineConfig>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Circular buffer for browser console messages dumped on render failure diagnostics.
|
|
59
|
+
// Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
|
|
60
|
+
const BROWSER_CONSOLE_BUFFER_SIZE = 200;
|
|
61
|
+
|
|
62
|
+
export async function createCaptureSession(
|
|
63
|
+
serverUrl: string,
|
|
64
|
+
outputDir: string,
|
|
65
|
+
options: CaptureOptions,
|
|
66
|
+
onBeforeCapture: BeforeCaptureHook | null = null,
|
|
67
|
+
config?: Partial<EngineConfig>,
|
|
68
|
+
): Promise<CaptureSession> {
|
|
69
|
+
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
// Determine capture mode before building args — BeginFrame flags only apply on Linux
|
|
72
|
+
const headlessShell = resolveHeadlessShellPath(config);
|
|
73
|
+
const isLinux = process.platform === "linux";
|
|
74
|
+
const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
|
|
75
|
+
const preMode: CaptureMode = headlessShell && isLinux && !forceScreenshot ? "beginframe" : "screenshot";
|
|
76
|
+
const chromeArgs = buildChromeArgs({ width: options.width, height: options.height, captureMode: preMode }, config);
|
|
77
|
+
|
|
78
|
+
const { browser, captureMode } = await acquireBrowser(chromeArgs, config);
|
|
79
|
+
|
|
80
|
+
const page = await browser.newPage();
|
|
81
|
+
const browserVersion = await browser.version();
|
|
82
|
+
const expectedMajor = config?.expectedChromiumMajor;
|
|
83
|
+
if (Number.isFinite(expectedMajor)) {
|
|
84
|
+
const actualChromiumMajor = Number.parseInt((browserVersion.match(/(\d+)\./) || [])[1] || "", 10);
|
|
85
|
+
if (Number.isFinite(actualChromiumMajor) && actualChromiumMajor !== expectedMajor) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[FrameCapture] Chromium major mismatch expected=${expectedMajor} actual=${actualChromiumMajor} raw=${browserVersion}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const viewport: Viewport = {
|
|
92
|
+
width: options.width,
|
|
93
|
+
height: options.height,
|
|
94
|
+
deviceScaleFactor: options.deviceScaleFactor || 1,
|
|
95
|
+
};
|
|
96
|
+
await page.setViewport(viewport);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
browser,
|
|
100
|
+
page,
|
|
101
|
+
options,
|
|
102
|
+
serverUrl,
|
|
103
|
+
outputDir,
|
|
104
|
+
onBeforeCapture,
|
|
105
|
+
isInitialized: false,
|
|
106
|
+
browserConsoleBuffer: [],
|
|
107
|
+
capturePerf: {
|
|
108
|
+
frames: 0,
|
|
109
|
+
seekMs: 0,
|
|
110
|
+
beforeCaptureMs: 0,
|
|
111
|
+
screenshotMs: 0,
|
|
112
|
+
totalMs: 0,
|
|
113
|
+
},
|
|
114
|
+
captureMode,
|
|
115
|
+
beginFrameTimeTicks: 0,
|
|
116
|
+
beginFrameIntervalMs: 1000 / Math.max(1, options.fps),
|
|
117
|
+
beginFrameHasDamageCount: 0,
|
|
118
|
+
beginFrameNoDamageCount: 0,
|
|
119
|
+
config,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function initializeSession(session: CaptureSession): Promise<void> {
|
|
124
|
+
const { page, serverUrl, options } = session;
|
|
125
|
+
|
|
126
|
+
// Forward browser console to host with [Browser] prefix
|
|
127
|
+
page.on("console", (msg: ConsoleMessage) => {
|
|
128
|
+
const type = msg.type();
|
|
129
|
+
const text = msg.text();
|
|
130
|
+
const prefix = type === "error" ? "[Browser:ERROR]" : type === "warn" ? "[Browser:WARN]" : "[Browser]";
|
|
131
|
+
console.log(`${prefix} ${text}`);
|
|
132
|
+
|
|
133
|
+
session.browserConsoleBuffer.push(`${prefix} ${text}`);
|
|
134
|
+
if (session.browserConsoleBuffer.length > BROWSER_CONSOLE_BUFFER_SIZE) {
|
|
135
|
+
session.browserConsoleBuffer.shift();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
page.on("pageerror", (err) => {
|
|
140
|
+
const text = `[Browser:PAGEERROR] ${err instanceof Error ? err.message : String(err)}`;
|
|
141
|
+
console.error(text);
|
|
142
|
+
session.browserConsoleBuffer.push(text);
|
|
143
|
+
if (session.browserConsoleBuffer.length > BROWSER_CONSOLE_BUFFER_SIZE) {
|
|
144
|
+
session.browserConsoleBuffer.shift();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Navigate to the file server
|
|
149
|
+
const url = `${serverUrl}/index.html`;
|
|
150
|
+
if (session.captureMode === "screenshot") {
|
|
151
|
+
// Screenshot mode: standard navigation, rAF works normally
|
|
152
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
153
|
+
|
|
154
|
+
const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
|
|
155
|
+
await page.waitForFunction(
|
|
156
|
+
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
|
|
157
|
+
{ timeout: pageReadyTimeout },
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Wait for all video elements to have loaded metadata (dimensions + duration)
|
|
161
|
+
// Without this, frame 0 captures videos at their 300x150 default size
|
|
162
|
+
await page.waitForFunction(
|
|
163
|
+
`document.querySelectorAll("video").length === 0 || Array.from(document.querySelectorAll("video")).every(v => v.readyState >= 1)`,
|
|
164
|
+
{ timeout: pageReadyTimeout },
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await page.evaluate(`document.fonts?.ready`);
|
|
168
|
+
|
|
169
|
+
session.isInitialized = true;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// In BeginFrame mode, Chrome's event loop is paused until we issue frames.
|
|
174
|
+
// Start a warmup loop to drive rAF/setTimeout callbacks during page load.
|
|
175
|
+
let warmupRunning = true;
|
|
176
|
+
let warmupTicks = 0;
|
|
177
|
+
let warmupFrameTime = 0;
|
|
178
|
+
const warmupIntervalMs = 33; // ~30fps
|
|
179
|
+
let warmupClient: import("puppeteer-core").CDPSession | null = null;
|
|
180
|
+
|
|
181
|
+
const warmupLoop = async () => {
|
|
182
|
+
try {
|
|
183
|
+
warmupClient = await getCdpSession(page);
|
|
184
|
+
await warmupClient.send("HeadlessExperimental.enable");
|
|
185
|
+
} catch {
|
|
186
|
+
/* page not ready yet */
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
while (warmupRunning) {
|
|
190
|
+
if (warmupClient) {
|
|
191
|
+
try {
|
|
192
|
+
await warmupClient.send("HeadlessExperimental.beginFrame", {
|
|
193
|
+
frameTimeTicks: warmupFrameTime,
|
|
194
|
+
interval: warmupIntervalMs,
|
|
195
|
+
noDisplayUpdates: true,
|
|
196
|
+
});
|
|
197
|
+
warmupFrameTime += warmupIntervalMs;
|
|
198
|
+
warmupTicks++;
|
|
199
|
+
} catch {
|
|
200
|
+
/* ignore warmup errors */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await new Promise((r) => setTimeout(r, warmupIntervalMs));
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
warmupLoop().catch(() => {});
|
|
207
|
+
|
|
208
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
209
|
+
|
|
210
|
+
// Poll for window.__hf readiness using manual evaluate loop (waitForFunction
|
|
211
|
+
// uses rAF polling internally, which won't fire in beginFrame mode).
|
|
212
|
+
const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
|
|
213
|
+
const pollDeadline = Date.now() + pageReadyTimeout;
|
|
214
|
+
while (Date.now() < pollDeadline) {
|
|
215
|
+
const ready = await page.evaluate(
|
|
216
|
+
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
|
|
217
|
+
);
|
|
218
|
+
if (ready) break;
|
|
219
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
220
|
+
}
|
|
221
|
+
const pageReady = await page.evaluate(
|
|
222
|
+
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
|
|
223
|
+
);
|
|
224
|
+
if (!pageReady) {
|
|
225
|
+
warmupRunning = false;
|
|
226
|
+
throw new Error(
|
|
227
|
+
`[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Wait for all video elements to have loaded metadata (dimensions + duration).
|
|
232
|
+
// Without this, frame 0 captures videos at their 300x150 default size.
|
|
233
|
+
const videoDeadline = Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
|
|
234
|
+
while (Date.now() < videoDeadline) {
|
|
235
|
+
const videosReady = await page.evaluate(
|
|
236
|
+
`document.querySelectorAll("video").length === 0 || Array.from(document.querySelectorAll("video")).every(v => v.readyState >= 1)`,
|
|
237
|
+
);
|
|
238
|
+
if (videosReady) break;
|
|
239
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Font check (no rAF dependency — uses fonts.ready API directly)
|
|
243
|
+
await page.evaluate(`document.fonts?.ready`);
|
|
244
|
+
|
|
245
|
+
// Stop warmup
|
|
246
|
+
warmupRunning = false;
|
|
247
|
+
|
|
248
|
+
// Set base frame time ticks past warmup range
|
|
249
|
+
session.beginFrameTimeTicks = (warmupTicks + 10) * session.beginFrameIntervalMs;
|
|
250
|
+
|
|
251
|
+
session.isInitialized = true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function captureFrameErrorDiagnostics(
|
|
255
|
+
session: CaptureSession,
|
|
256
|
+
frameIndex: number,
|
|
257
|
+
time: number,
|
|
258
|
+
error: Error,
|
|
259
|
+
): Promise<string | null> {
|
|
260
|
+
try {
|
|
261
|
+
const diagnosticsDir = join(session.outputDir, "diagnostics");
|
|
262
|
+
if (!existsSync(diagnosticsDir)) mkdirSync(diagnosticsDir, { recursive: true });
|
|
263
|
+
const base = join(diagnosticsDir, `frame-error-${frameIndex}`);
|
|
264
|
+
await session.page.screenshot({ path: `${base}.png`, type: "png", fullPage: true });
|
|
265
|
+
const html = await session.page.content();
|
|
266
|
+
writeFileSync(`${base}.html`, html, "utf-8");
|
|
267
|
+
writeFileSync(
|
|
268
|
+
`${base}.json`,
|
|
269
|
+
JSON.stringify(
|
|
270
|
+
{
|
|
271
|
+
frameIndex,
|
|
272
|
+
time,
|
|
273
|
+
error: error.message,
|
|
274
|
+
stack: error.stack,
|
|
275
|
+
browserConsoleTail: session.browserConsoleBuffer.slice(-30),
|
|
276
|
+
},
|
|
277
|
+
null,
|
|
278
|
+
2,
|
|
279
|
+
),
|
|
280
|
+
"utf-8",
|
|
281
|
+
);
|
|
282
|
+
return `${base}.json`;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Internal helper: seek timeline and inject video frames.
|
|
290
|
+
* Shared by captureFrame (disk) and captureFrameToBuffer (buffer).
|
|
291
|
+
* Returns timing breakdown for perf tracking.
|
|
292
|
+
*/
|
|
293
|
+
async function prepareFrameForCapture(
|
|
294
|
+
session: CaptureSession,
|
|
295
|
+
frameIndex: number,
|
|
296
|
+
time: number,
|
|
297
|
+
): Promise<{
|
|
298
|
+
quantizedTime: number;
|
|
299
|
+
seekMs: number;
|
|
300
|
+
beforeCaptureMs: number;
|
|
301
|
+
}> {
|
|
302
|
+
const { page, options } = session;
|
|
303
|
+
|
|
304
|
+
if (!session.isInitialized) {
|
|
305
|
+
throw new Error("[FrameCapture] Session not initialized");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const quantizedTime = quantizeTimeToFrame(time, options.fps);
|
|
309
|
+
|
|
310
|
+
const seekStart = Date.now();
|
|
311
|
+
// Seek via the __hf protocol. The page's seek() implementation handles
|
|
312
|
+
// all framework-specific logic (GSAP stepping, CSS animation sync, etc.)
|
|
313
|
+
await page.evaluate((t: number) => {
|
|
314
|
+
if (window.__hf && typeof window.__hf.seek === "function") {
|
|
315
|
+
window.__hf.seek(t);
|
|
316
|
+
}
|
|
317
|
+
}, quantizedTime);
|
|
318
|
+
const seekMs = Date.now() - seekStart;
|
|
319
|
+
|
|
320
|
+
// Before-capture hook (e.g. video frame injection)
|
|
321
|
+
const beforeCaptureStart = Date.now();
|
|
322
|
+
if (session.onBeforeCapture) {
|
|
323
|
+
await session.onBeforeCapture(page, quantizedTime);
|
|
324
|
+
}
|
|
325
|
+
const beforeCaptureMs = Date.now() - beforeCaptureStart;
|
|
326
|
+
|
|
327
|
+
return { quantizedTime, seekMs, beforeCaptureMs };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Internal core: prepare, screenshot, and track perf.
|
|
332
|
+
* Shared by captureFrame (disk) and captureFrameToBuffer (buffer).
|
|
333
|
+
* Returns the screenshot buffer, quantized time, and total capture time.
|
|
334
|
+
*/
|
|
335
|
+
async function captureFrameCore(
|
|
336
|
+
session: CaptureSession,
|
|
337
|
+
frameIndex: number,
|
|
338
|
+
time: number,
|
|
339
|
+
): Promise<{ buffer: Buffer; quantizedTime: number; captureTimeMs: number }> {
|
|
340
|
+
const { page, options } = session;
|
|
341
|
+
const startTime = Date.now();
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time);
|
|
345
|
+
|
|
346
|
+
const screenshotStart = Date.now();
|
|
347
|
+
let screenshotBuffer: Buffer;
|
|
348
|
+
|
|
349
|
+
if (session.captureMode === "beginframe") {
|
|
350
|
+
const frameTimeTicks = session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
|
|
351
|
+
const result = await beginFrameCapture(page, options, frameTimeTicks, session.beginFrameIntervalMs);
|
|
352
|
+
if (result.hasDamage) session.beginFrameHasDamageCount++;
|
|
353
|
+
else session.beginFrameNoDamageCount++;
|
|
354
|
+
screenshotBuffer = result.buffer;
|
|
355
|
+
} else {
|
|
356
|
+
screenshotBuffer = await pageScreenshotCapture(page, options);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const screenshotMs = Date.now() - screenshotStart;
|
|
360
|
+
const captureTimeMs = Date.now() - startTime;
|
|
361
|
+
|
|
362
|
+
session.capturePerf.frames += 1;
|
|
363
|
+
session.capturePerf.seekMs += seekMs;
|
|
364
|
+
session.capturePerf.beforeCaptureMs += beforeCaptureMs;
|
|
365
|
+
session.capturePerf.screenshotMs += screenshotMs;
|
|
366
|
+
session.capturePerf.totalMs += captureTimeMs;
|
|
367
|
+
|
|
368
|
+
return { buffer: screenshotBuffer, quantizedTime, captureTimeMs };
|
|
369
|
+
} catch (captureError) {
|
|
370
|
+
if (session.isInitialized) {
|
|
371
|
+
await captureFrameErrorDiagnostics(
|
|
372
|
+
session,
|
|
373
|
+
frameIndex,
|
|
374
|
+
time,
|
|
375
|
+
captureError instanceof Error ? captureError : new Error(String(captureError)),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
throw captureError;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function captureFrame(session: CaptureSession, frameIndex: number, time: number): Promise<CaptureResult> {
|
|
383
|
+
const { options, outputDir } = session;
|
|
384
|
+
const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
|
|
385
|
+
|
|
386
|
+
const ext = options.format === "png" ? "png" : "jpg";
|
|
387
|
+
const frameName = `frame_${String(frameIndex).padStart(6, "0")}.${ext}`;
|
|
388
|
+
const framePath = join(outputDir, frameName);
|
|
389
|
+
writeFileSync(framePath, buffer);
|
|
390
|
+
|
|
391
|
+
return { frameIndex, time: quantizedTime, path: framePath, captureTimeMs };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Capture a frame and return the screenshot as a Buffer instead of writing to disk.
|
|
396
|
+
* Used by the streaming encode pipeline to pipe frames directly to FFmpeg stdin.
|
|
397
|
+
*/
|
|
398
|
+
export async function captureFrameToBuffer(
|
|
399
|
+
session: CaptureSession,
|
|
400
|
+
frameIndex: number,
|
|
401
|
+
time: number,
|
|
402
|
+
): Promise<CaptureBufferResult> {
|
|
403
|
+
const { buffer, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
|
|
404
|
+
|
|
405
|
+
return { buffer, captureTimeMs };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function closeCaptureSession(session: CaptureSession): Promise<void> {
|
|
409
|
+
if (session.page) await session.page.close().catch(() => {});
|
|
410
|
+
if (session.browser) await releaseBrowser(session.browser, session.config);
|
|
411
|
+
session.isInitialized = false;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function prepareCaptureSessionForReuse(
|
|
415
|
+
session: CaptureSession,
|
|
416
|
+
outputDir: string,
|
|
417
|
+
onBeforeCapture: BeforeCaptureHook | null,
|
|
418
|
+
): void {
|
|
419
|
+
if (!existsSync(outputDir)) {
|
|
420
|
+
mkdirSync(outputDir, { recursive: true });
|
|
421
|
+
}
|
|
422
|
+
session.outputDir = outputDir;
|
|
423
|
+
session.onBeforeCapture = onBeforeCapture;
|
|
424
|
+
session.capturePerf = {
|
|
425
|
+
frames: 0,
|
|
426
|
+
seekMs: 0,
|
|
427
|
+
beforeCaptureMs: 0,
|
|
428
|
+
screenshotMs: 0,
|
|
429
|
+
totalMs: 0,
|
|
430
|
+
};
|
|
431
|
+
session.beginFrameHasDamageCount = 0;
|
|
432
|
+
session.beginFrameNoDamageCount = 0;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function getCompositionDuration(session: CaptureSession): Promise<number> {
|
|
436
|
+
if (!session.isInitialized) throw new Error("[FrameCapture] Session not initialized");
|
|
437
|
+
|
|
438
|
+
return session.page.evaluate(() => {
|
|
439
|
+
return window.__hf?.duration ?? 0;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function getCapturePerfSummary(session: CaptureSession): CapturePerfSummary {
|
|
444
|
+
const frames = Math.max(1, session.capturePerf.frames);
|
|
445
|
+
return {
|
|
446
|
+
frames: session.capturePerf.frames,
|
|
447
|
+
avgTotalMs: Math.round(session.capturePerf.totalMs / frames),
|
|
448
|
+
avgSeekMs: Math.round(session.capturePerf.seekMs / frames),
|
|
449
|
+
avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
|
|
450
|
+
avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { distributeFrames } from "./parallelCoordinator.js";
|
|
3
|
+
|
|
4
|
+
describe("distributeFrames", () => {
|
|
5
|
+
it("distributes frames evenly across workers", () => {
|
|
6
|
+
const tasks = distributeFrames(100, 4, "/tmp/work");
|
|
7
|
+
expect(tasks).toHaveLength(4);
|
|
8
|
+
|
|
9
|
+
// First worker: frames 0-24
|
|
10
|
+
expect(tasks[0]?.startFrame).toBe(0);
|
|
11
|
+
expect(tasks[0]?.endFrame).toBe(25);
|
|
12
|
+
|
|
13
|
+
// Last worker: frames 75-99
|
|
14
|
+
expect(tasks[3]?.startFrame).toBe(75);
|
|
15
|
+
expect(tasks[3]?.endFrame).toBe(100);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("handles single worker", () => {
|
|
19
|
+
const tasks = distributeFrames(50, 1, "/tmp/work");
|
|
20
|
+
expect(tasks).toHaveLength(1);
|
|
21
|
+
expect(tasks[0]?.startFrame).toBe(0);
|
|
22
|
+
expect(tasks[0]?.endFrame).toBe(50);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("does not create empty tasks when workers exceed frames", () => {
|
|
26
|
+
const tasks = distributeFrames(3, 10, "/tmp/work");
|
|
27
|
+
// Can't have more tasks than frames
|
|
28
|
+
expect(tasks.length).toBeLessThanOrEqual(3);
|
|
29
|
+
// All frames are covered
|
|
30
|
+
const totalFrames = tasks.reduce((sum, t) => sum + (t.endFrame - t.startFrame), 0);
|
|
31
|
+
expect(totalFrames).toBe(3);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("assigns worker output directories", () => {
|
|
35
|
+
const tasks = distributeFrames(60, 2, "/tmp/my-work");
|
|
36
|
+
expect(tasks[0]?.outputDir).toContain("worker-0");
|
|
37
|
+
expect(tasks[1]?.outputDir).toContain("worker-1");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("assigns sequential worker IDs", () => {
|
|
41
|
+
const tasks = distributeFrames(100, 3, "/tmp/work");
|
|
42
|
+
expect(tasks.map((t) => t.workerId)).toEqual([0, 1, 2]);
|
|
43
|
+
});
|
|
44
|
+
});
|