@hyperframes/engine 0.1.0 → 0.1.2

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 (107) hide show
  1. package/README.md +78 -0
  2. package/package.json +5 -3
  3. package/src/config.ts +32 -8
  4. package/src/index.ts +15 -17
  5. package/src/services/audioMixer.ts +33 -8
  6. package/src/services/browserManager.ts +7 -2
  7. package/src/services/chunkEncoder.ts +24 -3
  8. package/src/services/fileServer.ts +3 -1
  9. package/src/services/frameCapture.ts +46 -13
  10. package/src/services/parallelCoordinator.ts +25 -5
  11. package/src/services/screenshotService.ts +4 -1
  12. package/src/services/videoFrameExtractor.ts +19 -4
  13. package/src/services/videoFrameInjector.ts +9 -3
  14. package/src/utils/ffprobe.ts +25 -4
  15. package/src/utils/urlDownloader.ts +5 -1
  16. package/dist/config.d.ts +0 -62
  17. package/dist/config.d.ts.map +0 -1
  18. package/dist/config.js +0 -92
  19. package/dist/config.js.map +0 -1
  20. package/dist/config.test.d.ts +0 -2
  21. package/dist/config.test.d.ts.map +0 -1
  22. package/dist/config.test.js +0 -77
  23. package/dist/config.test.js.map +0 -1
  24. package/dist/index.d.ts +0 -49
  25. package/dist/index.d.ts.map +0 -1
  26. package/dist/index.js +0 -55
  27. package/dist/index.js.map +0 -1
  28. package/dist/services/audioMixer.d.ts +0 -11
  29. package/dist/services/audioMixer.d.ts.map +0 -1
  30. package/dist/services/audioMixer.js +0 -306
  31. package/dist/services/audioMixer.js.map +0 -1
  32. package/dist/services/audioMixer.types.d.ts +0 -27
  33. package/dist/services/audioMixer.types.d.ts.map +0 -1
  34. package/dist/services/audioMixer.types.js +0 -2
  35. package/dist/services/audioMixer.types.js.map +0 -1
  36. package/dist/services/browserManager.d.ts +0 -29
  37. package/dist/services/browserManager.d.ts.map +0 -1
  38. package/dist/services/browserManager.js +0 -175
  39. package/dist/services/browserManager.js.map +0 -1
  40. package/dist/services/chunkEncoder.d.ts +0 -32
  41. package/dist/services/chunkEncoder.d.ts.map +0 -1
  42. package/dist/services/chunkEncoder.js +0 -347
  43. package/dist/services/chunkEncoder.js.map +0 -1
  44. package/dist/services/chunkEncoder.test.d.ts +0 -2
  45. package/dist/services/chunkEncoder.test.d.ts.map +0 -1
  46. package/dist/services/chunkEncoder.test.js +0 -24
  47. package/dist/services/chunkEncoder.test.js.map +0 -1
  48. package/dist/services/chunkEncoder.types.d.ts +0 -26
  49. package/dist/services/chunkEncoder.types.d.ts.map +0 -1
  50. package/dist/services/chunkEncoder.types.js +0 -2
  51. package/dist/services/chunkEncoder.types.js.map +0 -1
  52. package/dist/services/fileServer.d.ts +0 -25
  53. package/dist/services/fileServer.d.ts.map +0 -1
  54. package/dist/services/fileServer.js +0 -138
  55. package/dist/services/fileServer.js.map +0 -1
  56. package/dist/services/frameCapture.d.ts +0 -52
  57. package/dist/services/frameCapture.d.ts.map +0 -1
  58. package/dist/services/frameCapture.js +0 -317
  59. package/dist/services/frameCapture.js.map +0 -1
  60. package/dist/services/parallelCoordinator.d.ts +0 -40
  61. package/dist/services/parallelCoordinator.d.ts.map +0 -1
  62. package/dist/services/parallelCoordinator.js +0 -192
  63. package/dist/services/parallelCoordinator.js.map +0 -1
  64. package/dist/services/parallelCoordinator.test.d.ts +0 -2
  65. package/dist/services/parallelCoordinator.test.d.ts.map +0 -1
  66. package/dist/services/parallelCoordinator.test.js +0 -38
  67. package/dist/services/parallelCoordinator.test.js.map +0 -1
  68. package/dist/services/screenshotService.d.ts +0 -28
  69. package/dist/services/screenshotService.d.ts.map +0 -1
  70. package/dist/services/screenshotService.js +0 -190
  71. package/dist/services/screenshotService.js.map +0 -1
  72. package/dist/services/streamingEncoder.d.ts +0 -49
  73. package/dist/services/streamingEncoder.d.ts.map +0 -1
  74. package/dist/services/streamingEncoder.js +0 -227
  75. package/dist/services/streamingEncoder.js.map +0 -1
  76. package/dist/services/videoFrameExtractor.d.ts +0 -65
  77. package/dist/services/videoFrameExtractor.d.ts.map +0 -1
  78. package/dist/services/videoFrameExtractor.js +0 -302
  79. package/dist/services/videoFrameExtractor.js.map +0 -1
  80. package/dist/services/videoFrameInjector.d.ts +0 -17
  81. package/dist/services/videoFrameInjector.d.ts.map +0 -1
  82. package/dist/services/videoFrameInjector.js +0 -92
  83. package/dist/services/videoFrameInjector.js.map +0 -1
  84. package/dist/types.d.ts +0 -79
  85. package/dist/types.d.ts.map +0 -1
  86. package/dist/types.js +0 -8
  87. package/dist/types.js.map +0 -1
  88. package/dist/utils/ffprobe.d.ts +0 -18
  89. package/dist/utils/ffprobe.d.ts.map +0 -1
  90. package/dist/utils/ffprobe.js +0 -137
  91. package/dist/utils/ffprobe.js.map +0 -1
  92. package/dist/utils/gpuEncoder.d.ts +0 -11
  93. package/dist/utils/gpuEncoder.d.ts.map +0 -1
  94. package/dist/utils/gpuEncoder.js +0 -55
  95. package/dist/utils/gpuEncoder.js.map +0 -1
  96. package/dist/utils/parityContract.d.ts +0 -3
  97. package/dist/utils/parityContract.d.ts.map +0 -1
  98. package/dist/utils/parityContract.js +0 -35
  99. package/dist/utils/parityContract.js.map +0 -1
  100. package/dist/utils/runFfmpeg.d.ts +0 -19
  101. package/dist/utils/runFfmpeg.d.ts.map +0 -1
  102. package/dist/utils/runFfmpeg.js +0 -62
  103. package/dist/utils/runFfmpeg.js.map +0 -1
  104. package/dist/utils/urlDownloader.d.ts +0 -3
  105. package/dist/utils/urlDownloader.d.ts.map +0 -1
  106. package/dist/utils/urlDownloader.js +0 -68
  107. package/dist/utils/urlDownloader.js.map +0 -1
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @hyperframes/engine
2
+
3
+ Seekable web-page-to-video rendering engine built on Puppeteer and FFmpeg.
4
+
5
+ Framework-agnostic: works with GSAP, Lottie, Three.js, CSS animations, or any web content that implements the `window.__hf` seek protocol.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @hyperframes/engine
11
+ ```
12
+
13
+ **Requirements:** Node.js >= 22, Chrome/Chromium (auto-downloaded by Puppeteer), FFmpeg
14
+
15
+ ## What it does
16
+
17
+ The engine opens your HTML composition in a headless Chrome instance, seeks frame-by-frame using Chrome's `HeadlessExperimental.beginFrame` API, captures screenshots, and encodes them into video with FFmpeg.
18
+
19
+ ### Key services
20
+
21
+ | Service | Description |
22
+ | ----------------------- | ---------------------------------------------------------------------- |
23
+ | **browserManager** | Launches and pools headless Chrome instances (`chrome-headless-shell`) |
24
+ | **frameCapture** | Manages capture sessions — seek, screenshot, buffer lifecycle |
25
+ | **screenshotService** | BeginFrame-based capture with CDP (Chrome DevTools Protocol) |
26
+ | **chunkEncoder** | FFmpeg encoding with chunked concat, GPU detection, faststart |
27
+ | **streamingEncoder** | Pipe frames to FFmpeg in real time (no intermediate PNGs on disk) |
28
+ | **audioMixer** | Parse `<audio>` elements and mix audio tracks via FFmpeg |
29
+ | **videoFrameExtractor** | Extract frames from `<video>` elements for compositing |
30
+ | **parallelCoordinator** | Split frame ranges across worker processes |
31
+ | **fileServer** | Serve local HTML files to the browser via Hono |
32
+
33
+ ## Usage
34
+
35
+ ```typescript
36
+ import {
37
+ acquireBrowser,
38
+ releaseBrowser,
39
+ createCaptureSession,
40
+ initializeSession,
41
+ captureFrame,
42
+ closeCaptureSession,
43
+ } from "@hyperframes/engine";
44
+
45
+ // 1. Launch browser
46
+ const browser = await acquireBrowser({ captureMode: "beginFrame" });
47
+
48
+ // 2. Open a capture session
49
+ const session = createCaptureSession({
50
+ browser: browser.browser,
51
+ url: "http://localhost:3000/my-composition.html",
52
+ width: 1920,
53
+ height: 1080,
54
+ fps: 30,
55
+ });
56
+ await initializeSession(session);
57
+
58
+ // 3. Capture frames
59
+ for (let i = 0; i < totalFrames; i++) {
60
+ await captureFrame(session, i, `/tmp/frames/frame-${i}.png`);
61
+ }
62
+
63
+ // 4. Clean up
64
+ await closeCaptureSession(session);
65
+ await releaseBrowser(browser);
66
+ ```
67
+
68
+ Most users should use `@hyperframes/producer` or the `hyperframes` CLI instead of calling the engine directly.
69
+
70
+ ## Documentation
71
+
72
+ Full documentation: [hyperframes.heygen.com/packages/engine](https://hyperframes.heygen.com/packages/engine)
73
+
74
+ ## Related packages
75
+
76
+ - [`@hyperframes/core`](../core) — types, parsers, frame adapters
77
+ - [`@hyperframes/producer`](../producer) — high-level render pipeline built on this engine
78
+ - [`hyperframes`](../cli) — CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -10,11 +10,12 @@
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc",
13
+ "test": "vitest run",
13
14
  "typecheck": "tsc --noEmit"
14
15
  },
15
16
  "dependencies": {
16
17
  "@hono/node-server": "^1.13.0",
17
- "@hyperframes/core": "workspace:*",
18
+ "@hyperframes/core": "workspace:^",
18
19
  "hono": "^4.6.0",
19
20
  "linkedom": "^0.18.12",
20
21
  "puppeteer": "^24.0.0",
@@ -22,7 +23,8 @@
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/node": "^22.10.1",
25
- "typescript": "^5.7.2"
26
+ "typescript": "^5.7.2",
27
+ "vitest": "^3.2.4"
26
28
  },
27
29
  "engines": {
28
30
  "node": ">=22"
package/src/config.ts CHANGED
@@ -131,26 +131,44 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
131
131
  concurrency: env("PRODUCER_MAX_WORKERS") ? Number(env("PRODUCER_MAX_WORKERS")) : undefined,
132
132
  coresPerWorker: envNum("PRODUCER_CORES_PER_WORKER", DEFAULT_CONFIG.coresPerWorker),
133
133
  minParallelFrames: envNum("PRODUCER_MIN_PARALLEL_FRAMES", DEFAULT_CONFIG.minParallelFrames),
134
- largeRenderThreshold: envNum("PRODUCER_LARGE_RENDER_THRESHOLD", DEFAULT_CONFIG.largeRenderThreshold),
134
+ largeRenderThreshold: envNum(
135
+ "PRODUCER_LARGE_RENDER_THRESHOLD",
136
+ DEFAULT_CONFIG.largeRenderThreshold,
137
+ ),
135
138
 
136
139
  chromePath: env("PRODUCER_HEADLESS_SHELL_PATH"),
137
140
  disableGpu: envBool("PRODUCER_DISABLE_GPU", DEFAULT_CONFIG.disableGpu),
138
141
  enableBrowserPool: envBool("PRODUCER_ENABLE_BROWSER_POOL", DEFAULT_CONFIG.enableBrowserPool),
139
142
  browserTimeout: envNum("PRODUCER_PUPPETEER_LAUNCH_TIMEOUT_MS", DEFAULT_CONFIG.browserTimeout),
140
- protocolTimeout: envNum("PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS", DEFAULT_CONFIG.protocolTimeout),
143
+ protocolTimeout: envNum(
144
+ "PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS",
145
+ DEFAULT_CONFIG.protocolTimeout,
146
+ ),
141
147
  expectedChromiumMajor: env("PRODUCER_EXPECTED_CHROMIUM_MAJOR")
142
148
  ? Number(env("PRODUCER_EXPECTED_CHROMIUM_MAJOR"))
143
149
  : undefined,
144
150
 
145
151
  forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot),
146
152
 
147
- enableChunkedEncode: envBool("PRODUCER_ENABLE_CHUNKED_ENCODE", DEFAULT_CONFIG.enableChunkedEncode),
148
- chunkSizeFrames: Math.max(120, envNum("PRODUCER_CHUNK_SIZE_FRAMES", DEFAULT_CONFIG.chunkSizeFrames)),
149
- enableStreamingEncode: envBool("PRODUCER_ENABLE_STREAMING_ENCODE", DEFAULT_CONFIG.enableStreamingEncode),
153
+ enableChunkedEncode: envBool(
154
+ "PRODUCER_ENABLE_CHUNKED_ENCODE",
155
+ DEFAULT_CONFIG.enableChunkedEncode,
156
+ ),
157
+ chunkSizeFrames: Math.max(
158
+ 120,
159
+ envNum("PRODUCER_CHUNK_SIZE_FRAMES", DEFAULT_CONFIG.chunkSizeFrames),
160
+ ),
161
+ enableStreamingEncode: envBool(
162
+ "PRODUCER_ENABLE_STREAMING_ENCODE",
163
+ DEFAULT_CONFIG.enableStreamingEncode,
164
+ ),
150
165
 
151
166
  ffmpegEncodeTimeout: envNum("FFMPEG_ENCODE_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegEncodeTimeout),
152
167
  ffmpegProcessTimeout: envNum("FFMPEG_PROCESS_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegProcessTimeout),
153
- ffmpegStreamingTimeout: envNum("FFMPEG_STREAMING_TIMEOUT_MS", DEFAULT_CONFIG.ffmpegStreamingTimeout),
168
+ ffmpegStreamingTimeout: envNum(
169
+ "FFMPEG_STREAMING_TIMEOUT_MS",
170
+ DEFAULT_CONFIG.ffmpegStreamingTimeout,
171
+ ),
154
172
 
155
173
  audioGain: envNum("PRODUCER_AUDIO_GAIN", DEFAULT_CONFIG.audioGain),
156
174
  frameDataUriCacheLimit: Math.max(
@@ -158,8 +176,14 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {
158
176
  envNum("PRODUCER_FRAME_DATA_URI_CACHE_LIMIT", DEFAULT_CONFIG.frameDataUriCacheLimit),
159
177
  ),
160
178
 
161
- playerReadyTimeout: envNum("PRODUCER_PLAYER_READY_TIMEOUT_MS", DEFAULT_CONFIG.playerReadyTimeout),
162
- renderReadyTimeout: envNum("PRODUCER_RENDER_READY_TIMEOUT_MS", DEFAULT_CONFIG.renderReadyTimeout),
179
+ playerReadyTimeout: envNum(
180
+ "PRODUCER_PLAYER_READY_TIMEOUT_MS",
181
+ DEFAULT_CONFIG.playerReadyTimeout,
182
+ ),
183
+ renderReadyTimeout: envNum(
184
+ "PRODUCER_RENDER_READY_TIMEOUT_MS",
185
+ DEFAULT_CONFIG.renderReadyTimeout,
186
+ ),
163
187
 
164
188
  verifyRuntime: env("PRODUCER_VERIFY_HYPERFRAME_RUNTIME") !== "false",
165
189
  runtimeManifestPath: env("PRODUCER_HYPERFRAME_MANIFEST_PATH"),
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * - **Optional lookups return `T | undefined` or `T | null`.**
28
28
  * Functions that may legitimately find nothing (resolveHeadlessShellPath,
29
29
  * getFrameAtTime, detectGpuEncoder) return a nullable value instead of throwing.
30
- *
30
+ *
31
31
  */
32
32
 
33
33
  // ── Protocol types ─────────────────────────────────────────────────────────────
@@ -90,11 +90,7 @@ export {
90
90
  ENCODER_PRESETS,
91
91
  type GpuEncoder,
92
92
  } from "./services/chunkEncoder.js";
93
- export type {
94
- EncoderOptions,
95
- EncodeResult,
96
- MuxResult,
97
- } from "./services/chunkEncoder.types.js";
93
+ export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
98
94
 
99
95
  export {
100
96
  spawnStreamingEncoder,
@@ -121,15 +117,8 @@ export {
121
117
 
122
118
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
123
119
 
124
- export {
125
- parseAudioElements,
126
- processCompositionAudio,
127
- } from "./services/audioMixer.js";
128
- export type {
129
- AudioElement,
130
- AudioTrack,
131
- MixResult,
132
- } from "./services/audioMixer.types.js";
120
+ export { parseAudioElements, processCompositionAudio } from "./services/audioMixer.js";
121
+ export type { AudioElement, AudioTrack, MixResult } from "./services/audioMixer.types.js";
133
122
 
134
123
  // ── Parallel rendering ─────────────────────────────────────────────────────────
135
124
  export {
@@ -144,11 +133,20 @@ export {
144
133
  } from "./services/parallelCoordinator.js";
145
134
 
146
135
  // ── File server ────────────────────────────────────────────────────────────────
147
- export { createFileServer, type FileServerOptions, type FileServerHandle } from "./services/fileServer.js";
136
+ export {
137
+ createFileServer,
138
+ type FileServerOptions,
139
+ type FileServerHandle,
140
+ } from "./services/fileServer.js";
148
141
 
149
142
  // ── Utilities ──────────────────────────────────────────────────────────────────
150
143
  export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
151
144
 
152
- export { extractVideoMetadata, extractAudioMetadata, type VideoMetadata, type AudioMetadata } from "./utils/ffprobe.js";
145
+ export {
146
+ extractVideoMetadata,
147
+ extractAudioMetadata,
148
+ type VideoMetadata,
149
+ type AudioMetadata,
150
+ } from "./utils/ffprobe.js";
153
151
 
154
152
  export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
@@ -98,14 +98,20 @@ async function extractAudioFromVideo(
98
98
  const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
99
99
 
100
100
  if (signal?.aborted) {
101
- return { success: false, outputPath, durationMs: result.durationMs, error: "Audio extract cancelled" };
101
+ return {
102
+ success: false,
103
+ outputPath,
104
+ durationMs: result.durationMs,
105
+ error: "Audio extract cancelled",
106
+ };
102
107
  }
103
108
  if (!result.success) {
104
109
  return {
105
110
  success: false,
106
111
  outputPath,
107
112
  durationMs: result.durationMs,
108
- error: result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
113
+ error:
114
+ result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
109
115
  };
110
116
  }
111
117
  return { success: true, outputPath, durationMs: result.durationMs };
@@ -143,7 +149,12 @@ async function prepareAudioTrack(
143
149
  const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
144
150
 
145
151
  if (signal?.aborted) {
146
- return { success: false, outputPath, durationMs: result.durationMs, error: "Audio prepare cancelled" };
152
+ return {
153
+ success: false,
154
+ outputPath,
155
+ durationMs: result.durationMs,
156
+ error: "Audio prepare cancelled",
157
+ };
147
158
  }
148
159
  return {
149
160
  success: result.success,
@@ -183,7 +194,12 @@ async function generateSilence(
183
194
  const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
184
195
 
185
196
  if (signal?.aborted) {
186
- return { success: false, outputPath, durationMs: result.durationMs, error: "Silence generation cancelled" };
197
+ return {
198
+ success: false,
199
+ outputPath,
200
+ durationMs: result.durationMs,
201
+ error: "Silence generation cancelled",
202
+ };
187
203
  }
188
204
  return {
189
205
  success: result.success,
@@ -272,10 +288,16 @@ async function mixAudioTracks(
272
288
  outputPath,
273
289
  durationMs: result.durationMs,
274
290
  tracksProcessed: 0,
275
- error: result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
291
+ error:
292
+ result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
276
293
  };
277
294
  }
278
- return { success: true, outputPath, durationMs: result.durationMs, tracksProcessed: tracks.length };
295
+ return {
296
+ success: true,
297
+ outputPath,
298
+ durationMs: result.durationMs,
299
+ tracksProcessed: tracks.length,
300
+ };
279
301
  }
280
302
 
281
303
  export async function processCompositionAudio(
@@ -309,7 +331,9 @@ export async function processCompositionAudio(
309
331
  try {
310
332
  srcPath = await downloadToTemp(srcPath, workDir);
311
333
  } catch (err: unknown) {
312
- errors.push(`Download failed: ${element.id} — ${err instanceof Error ? err.message : String(err)}`);
334
+ errors.push(
335
+ `Download failed: ${element.id} — ${err instanceof Error ? err.message : String(err)}`,
336
+ );
313
337
  return;
314
338
  }
315
339
  }
@@ -323,7 +347,8 @@ export async function processCompositionAudio(
323
347
  if (element.end - element.start <= 0) {
324
348
  const metadata = await extractAudioMetadata(srcPath);
325
349
  const effectiveDuration = metadata.durationSeconds - element.mediaStart;
326
- element.end = element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
350
+ element.end =
351
+ element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
327
352
  }
328
353
 
329
354
  let audioSrcPath = srcPath;
@@ -40,7 +40,9 @@ export interface AcquiredBrowser {
40
40
  * Checks config.chromePath, then PRODUCER_HEADLESS_SHELL_PATH env var,
41
41
  * then scans Puppeteer's managed cache at ~/.cache/puppeteer/chrome-headless-shell/.
42
42
  */
43
- export function resolveHeadlessShellPath(config?: Partial<Pick<EngineConfig, "chromePath">>): string | undefined {
43
+ export function resolveHeadlessShellPath(
44
+ config?: Partial<Pick<EngineConfig, "chromePath">>,
45
+ ): string | undefined {
44
46
  if (config?.chromePath) {
45
47
  return config.chromePath;
46
48
  }
@@ -78,7 +80,10 @@ export const ENABLE_BROWSER_POOL = DEFAULT_CONFIG.enableBrowserPool;
78
80
  export async function acquireBrowser(
79
81
  chromeArgs: string[],
80
82
  config?: Partial<
81
- Pick<EngineConfig, "browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot">
83
+ Pick<
84
+ EngineConfig,
85
+ "browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot"
86
+ >
82
87
  >,
83
88
  ): Promise<AcquiredBrowser> {
84
89
  const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
@@ -289,7 +289,18 @@ export async function encodeFramesChunkedConcat(
289
289
  const concatInput = chunkPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join("\n");
290
290
  writeFileSync(concatListPath, concatInput, "utf-8");
291
291
 
292
- const concatArgs = ["-f", "concat", "-safe", "0", "-i", concatListPath, "-c", "copy", "-y", outputPath];
292
+ const concatArgs = [
293
+ "-f",
294
+ "concat",
295
+ "-safe",
296
+ "0",
297
+ "-i",
298
+ concatListPath,
299
+ "-c",
300
+ "copy",
301
+ "-y",
302
+ outputPath,
303
+ ];
293
304
  const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
294
305
  const ffmpeg = spawn("ffmpeg", concatArgs);
295
306
  let stderr = "";
@@ -358,7 +369,12 @@ export async function muxVideoWithAudio(
358
369
  const result = await runFfmpeg(args, { signal, timeout: processTimeout });
359
370
 
360
371
  if (signal?.aborted) {
361
- return { success: false, outputPath, durationMs: result.durationMs, error: "FFmpeg mux cancelled" };
372
+ return {
373
+ success: false,
374
+ outputPath,
375
+ durationMs: result.durationMs,
376
+ error: "FFmpeg mux cancelled",
377
+ };
362
378
  }
363
379
  return {
364
380
  success: result.success,
@@ -384,7 +400,12 @@ export async function applyFaststart(
384
400
  const result = await runFfmpeg(args, { signal, timeout: processTimeout });
385
401
 
386
402
  if (signal?.aborted) {
387
- return { success: false, outputPath, durationMs: result.durationMs, error: "FFmpeg faststart cancelled" };
403
+ return {
404
+ success: false,
405
+ outputPath,
406
+ durationMs: result.durationMs,
407
+ error: "FFmpeg faststart cancelled",
408
+ };
388
409
  }
389
410
  return {
390
411
  success: result.success,
@@ -135,7 +135,9 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
135
135
  // Remove leading slash
136
136
  const relativePath = requestPath.replace(/^\//, "");
137
137
  const compiledPath = compiledDir ? join(compiledDir, relativePath) : null;
138
- const hasCompiledFile = Boolean(compiledPath && existsSync(compiledPath) && statSync(compiledPath).isFile());
138
+ const hasCompiledFile = Boolean(
139
+ compiledPath && existsSync(compiledPath) && statSync(compiledPath).isFile(),
140
+ );
139
141
  const filePath = hasCompiledFile ? (compiledPath as string) : join(projectDir, relativePath);
140
142
 
141
143
  if (!existsSync(filePath) || !statSync(filePath).isFile()) {
@@ -22,7 +22,12 @@ import {
22
22
  } from "./browserManager.js";
23
23
  import { beginFrameCapture, getCdpSession, pageScreenshotCapture } from "./screenshotService.js";
24
24
  import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
25
- import type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary } from "../types.js";
25
+ import type {
26
+ CaptureOptions,
27
+ CaptureResult,
28
+ CaptureBufferResult,
29
+ CapturePerfSummary,
30
+ } from "../types.js";
26
31
 
27
32
  export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary };
28
33
 
@@ -72,8 +77,12 @@ export async function createCaptureSession(
72
77
  const headlessShell = resolveHeadlessShellPath(config);
73
78
  const isLinux = process.platform === "linux";
74
79
  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);
80
+ const preMode: CaptureMode =
81
+ headlessShell && isLinux && !forceScreenshot ? "beginframe" : "screenshot";
82
+ const chromeArgs = buildChromeArgs(
83
+ { width: options.width, height: options.height, captureMode: preMode },
84
+ config,
85
+ );
77
86
 
78
87
  const { browser, captureMode } = await acquireBrowser(chromeArgs, config);
79
88
 
@@ -81,7 +90,10 @@ export async function createCaptureSession(
81
90
  const browserVersion = await browser.version();
82
91
  const expectedMajor = config?.expectedChromiumMajor;
83
92
  if (Number.isFinite(expectedMajor)) {
84
- const actualChromiumMajor = Number.parseInt((browserVersion.match(/(\d+)\./) || [])[1] || "", 10);
93
+ const actualChromiumMajor = Number.parseInt(
94
+ (browserVersion.match(/(\d+)\./) || [])[1] || "",
95
+ 10,
96
+ );
85
97
  if (Number.isFinite(actualChromiumMajor) && actualChromiumMajor !== expectedMajor) {
86
98
  throw new Error(
87
99
  `[FrameCapture] Chromium major mismatch expected=${expectedMajor} actual=${actualChromiumMajor} raw=${browserVersion}`,
@@ -121,13 +133,14 @@ export async function createCaptureSession(
121
133
  }
122
134
 
123
135
  export async function initializeSession(session: CaptureSession): Promise<void> {
124
- const { page, serverUrl, options } = session;
136
+ const { page, serverUrl } = session;
125
137
 
126
138
  // Forward browser console to host with [Browser] prefix
127
139
  page.on("console", (msg: ConsoleMessage) => {
128
140
  const type = msg.type();
129
141
  const text = msg.text();
130
- const prefix = type === "error" ? "[Browser:ERROR]" : type === "warn" ? "[Browser:WARN]" : "[Browser]";
142
+ const prefix =
143
+ type === "error" ? "[Browser:ERROR]" : type === "warn" ? "[Browser:WARN]" : "[Browser]";
131
144
  console.log(`${prefix} ${text}`);
132
145
 
133
146
  session.browserConsoleBuffer.push(`${prefix} ${text}`);
@@ -151,7 +164,8 @@ export async function initializeSession(session: CaptureSession): Promise<void>
151
164
  // Screenshot mode: standard navigation, rAF works normally
152
165
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
153
166
 
154
- const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
167
+ const pageReadyTimeout =
168
+ session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
155
169
  await page.waitForFunction(
156
170
  `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
157
171
  { timeout: pageReadyTimeout },
@@ -230,7 +244,8 @@ export async function initializeSession(session: CaptureSession): Promise<void>
230
244
 
231
245
  // Wait for all video elements to have loaded metadata (dimensions + duration).
232
246
  // Without this, frame 0 captures videos at their 300x150 default size.
233
- const videoDeadline = Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
247
+ const videoDeadline =
248
+ Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
234
249
  while (Date.now() < videoDeadline) {
235
250
  const videosReady = await page.evaluate(
236
251
  `document.querySelectorAll("video").length === 0 || Array.from(document.querySelectorAll("video")).every(v => v.readyState >= 1)`,
@@ -341,14 +356,24 @@ async function captureFrameCore(
341
356
  const startTime = Date.now();
342
357
 
343
358
  try {
344
- const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time);
359
+ const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(
360
+ session,
361
+ frameIndex,
362
+ time,
363
+ );
345
364
 
346
365
  const screenshotStart = Date.now();
347
366
  let screenshotBuffer: Buffer;
348
367
 
349
368
  if (session.captureMode === "beginframe") {
350
- const frameTimeTicks = session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
351
- const result = await beginFrameCapture(page, options, frameTimeTicks, session.beginFrameIntervalMs);
369
+ const frameTimeTicks =
370
+ session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
371
+ const result = await beginFrameCapture(
372
+ page,
373
+ options,
374
+ frameTimeTicks,
375
+ session.beginFrameIntervalMs,
376
+ );
352
377
  if (result.hasDamage) session.beginFrameHasDamageCount++;
353
378
  else session.beginFrameNoDamageCount++;
354
379
  screenshotBuffer = result.buffer;
@@ -379,9 +404,17 @@ async function captureFrameCore(
379
404
  }
380
405
  }
381
406
 
382
- export async function captureFrame(session: CaptureSession, frameIndex: number, time: number): Promise<CaptureResult> {
407
+ export async function captureFrame(
408
+ session: CaptureSession,
409
+ frameIndex: number,
410
+ time: number,
411
+ ): Promise<CaptureResult> {
383
412
  const { options, outputDir } = session;
384
- const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
413
+ const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(
414
+ session,
415
+ frameIndex,
416
+ time,
417
+ );
385
418
 
386
419
  const ext = options.format === "png" ? "png" : "jpg";
387
420
  const frameName = `frame_${String(frameIndex).padStart(6, "0")}.${ext}`;
@@ -57,7 +57,12 @@ const MIN_FRAMES_PER_WORKER = 30;
57
57
  export function calculateOptimalWorkers(
58
58
  totalFrames: number,
59
59
  requested?: number,
60
- config?: Partial<Pick<EngineConfig, "concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold">>,
60
+ config?: Partial<
61
+ Pick<
62
+ EngineConfig,
63
+ "concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold"
64
+ >
65
+ >,
61
66
  ): number {
62
67
  // Resolve effective values: config overrides → DEFAULT_CONFIG fallback.
63
68
  const effectiveMaxWorkers = (() => {
@@ -69,7 +74,8 @@ export function calculateOptimalWorkers(
69
74
  })();
70
75
  const effectiveCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker;
71
76
  const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
72
- const effectiveLargeRenderThreshold = config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
77
+ const effectiveLargeRenderThreshold =
78
+ config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
73
79
 
74
80
  if (requested !== undefined) {
75
81
  return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
@@ -107,7 +113,11 @@ export function calculateOptimalWorkers(
107
113
  return finalWorkers;
108
114
  }
109
115
 
110
- export function distributeFrames(totalFrames: number, workerCount: number, workDir: string): WorkerTask[] {
116
+ export function distributeFrames(
117
+ totalFrames: number,
118
+ workerCount: number,
119
+ workDir: string,
120
+ ): WorkerTask[] {
111
121
  const tasks: WorkerTask[] = [];
112
122
  const framesPerWorker = Math.ceil(totalFrames / workerCount);
113
123
 
@@ -146,7 +156,13 @@ async function executeWorkerTask(
146
156
  let perf: CapturePerfSummary | undefined;
147
157
 
148
158
  try {
149
- session = await createCaptureSession(serverUrl, task.outputDir, captureOptions, createBeforeCaptureHook(), config);
159
+ session = await createCaptureSession(
160
+ serverUrl,
161
+ task.outputDir,
162
+ captureOptions,
163
+ createBeforeCaptureHook(),
164
+ config,
165
+ );
150
166
  await initializeSession(session);
151
167
 
152
168
  for (let i = task.startFrame; i < task.endFrame; i++) {
@@ -248,7 +264,11 @@ export async function executeParallelCapture(
248
264
  return results;
249
265
  }
250
266
 
251
- export async function mergeWorkerFrames(workDir: string, tasks: WorkerTask[], outputDir: string): Promise<number> {
267
+ export async function mergeWorkerFrames(
268
+ workDir: string,
269
+ tasks: WorkerTask[],
270
+ outputDir: string,
271
+ ): Promise<number> {
252
272
  if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
253
273
 
254
274
  let totalFrames = 0;
@@ -203,7 +203,10 @@ export async function injectVideoFramesBatch(
203
203
  );
204
204
  }
205
205
 
206
- export async function syncVideoFrameVisibility(page: Page, activeVideoIds: string[]): Promise<void> {
206
+ export async function syncVideoFrameVisibility(
207
+ page: Page,
208
+ activeVideoIds: string[],
209
+ ): Promise<void> {
207
210
  await page.evaluate((ids: string[]) => {
208
211
  const active = new Set(ids);
209
212
  const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
@@ -252,10 +252,20 @@ export async function extractAllVideoFrames(
252
252
  }
253
253
  }
254
254
 
255
- return { success: errors.length === 0, extracted, errors, totalFramesExtracted, durationMs: Date.now() - startTime };
255
+ return {
256
+ success: errors.length === 0,
257
+ extracted,
258
+ errors,
259
+ totalFramesExtracted,
260
+ durationMs: Date.now() - startTime,
261
+ };
256
262
  }
257
263
 
258
- export function getFrameAtTime(extracted: ExtractedFrames, globalTime: number, videoStart: number): string | null {
264
+ export function getFrameAtTime(
265
+ extracted: ExtractedFrames,
266
+ globalTime: number,
267
+ videoStart: number,
268
+ ): string | null {
259
269
  const localTime = globalTime - videoStart;
260
270
  if (localTime < 0) return null;
261
271
  const frameIndex = Math.floor(localTime * extracted.fps);
@@ -344,7 +354,9 @@ export class FrameLookupTable {
344
354
  this.lastTime = globalTime;
345
355
  }
346
356
 
347
- getActiveFramePayloads(globalTime: number): Map<string, { framePath: string; frameIndex: number }> {
357
+ getActiveFramePayloads(
358
+ globalTime: number,
359
+ ): Map<string, { framePath: string; frameIndex: number }> {
348
360
  const frames = new Map<string, { framePath: string; frameIndex: number }>();
349
361
  this.refreshActiveSet(globalTime);
350
362
  for (const videoId of this.activeVideoIds) {
@@ -381,7 +393,10 @@ export class FrameLookupTable {
381
393
  }
382
394
  }
383
395
 
384
- export function createFrameLookupTable(videos: VideoElement[], extracted: ExtractedFrames[]): FrameLookupTable {
396
+ export function createFrameLookupTable(
397
+ videos: VideoElement[],
398
+ extracted: ExtractedFrames[],
399
+ ): FrameLookupTable {
385
400
  const table = new FrameLookupTable();
386
401
  const extractedMap = new Map<string, ExtractedFrames>();
387
402
  for (const ext of extracted) extractedMap.set(ext.videoId, ext);