@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.
- package/README.md +78 -0
- package/package.json +5 -3
- package/src/config.ts +32 -8
- package/src/index.ts +15 -17
- package/src/services/audioMixer.ts +33 -8
- package/src/services/browserManager.ts +7 -2
- package/src/services/chunkEncoder.ts +24 -3
- package/src/services/fileServer.ts +3 -1
- package/src/services/frameCapture.ts +46 -13
- package/src/services/parallelCoordinator.ts +25 -5
- package/src/services/screenshotService.ts +4 -1
- package/src/services/videoFrameExtractor.ts +19 -4
- package/src/services/videoFrameInjector.ts +9 -3
- package/src/utils/ffprobe.ts +25 -4
- package/src/utils/urlDownloader.ts +5 -1
- package/dist/config.d.ts +0 -62
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -92
- package/dist/config.js.map +0 -1
- package/dist/config.test.d.ts +0 -2
- package/dist/config.test.d.ts.map +0 -1
- package/dist/config.test.js +0 -77
- package/dist/config.test.js.map +0 -1
- package/dist/index.d.ts +0 -49
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -55
- package/dist/index.js.map +0 -1
- package/dist/services/audioMixer.d.ts +0 -11
- package/dist/services/audioMixer.d.ts.map +0 -1
- package/dist/services/audioMixer.js +0 -306
- package/dist/services/audioMixer.js.map +0 -1
- package/dist/services/audioMixer.types.d.ts +0 -27
- package/dist/services/audioMixer.types.d.ts.map +0 -1
- package/dist/services/audioMixer.types.js +0 -2
- package/dist/services/audioMixer.types.js.map +0 -1
- package/dist/services/browserManager.d.ts +0 -29
- package/dist/services/browserManager.d.ts.map +0 -1
- package/dist/services/browserManager.js +0 -175
- package/dist/services/browserManager.js.map +0 -1
- package/dist/services/chunkEncoder.d.ts +0 -32
- package/dist/services/chunkEncoder.d.ts.map +0 -1
- package/dist/services/chunkEncoder.js +0 -347
- package/dist/services/chunkEncoder.js.map +0 -1
- package/dist/services/chunkEncoder.test.d.ts +0 -2
- package/dist/services/chunkEncoder.test.d.ts.map +0 -1
- package/dist/services/chunkEncoder.test.js +0 -24
- package/dist/services/chunkEncoder.test.js.map +0 -1
- package/dist/services/chunkEncoder.types.d.ts +0 -26
- package/dist/services/chunkEncoder.types.d.ts.map +0 -1
- package/dist/services/chunkEncoder.types.js +0 -2
- package/dist/services/chunkEncoder.types.js.map +0 -1
- package/dist/services/fileServer.d.ts +0 -25
- package/dist/services/fileServer.d.ts.map +0 -1
- package/dist/services/fileServer.js +0 -138
- package/dist/services/fileServer.js.map +0 -1
- package/dist/services/frameCapture.d.ts +0 -52
- package/dist/services/frameCapture.d.ts.map +0 -1
- package/dist/services/frameCapture.js +0 -317
- package/dist/services/frameCapture.js.map +0 -1
- package/dist/services/parallelCoordinator.d.ts +0 -40
- package/dist/services/parallelCoordinator.d.ts.map +0 -1
- package/dist/services/parallelCoordinator.js +0 -192
- package/dist/services/parallelCoordinator.js.map +0 -1
- package/dist/services/parallelCoordinator.test.d.ts +0 -2
- package/dist/services/parallelCoordinator.test.d.ts.map +0 -1
- package/dist/services/parallelCoordinator.test.js +0 -38
- package/dist/services/parallelCoordinator.test.js.map +0 -1
- package/dist/services/screenshotService.d.ts +0 -28
- package/dist/services/screenshotService.d.ts.map +0 -1
- package/dist/services/screenshotService.js +0 -190
- package/dist/services/screenshotService.js.map +0 -1
- package/dist/services/streamingEncoder.d.ts +0 -49
- package/dist/services/streamingEncoder.d.ts.map +0 -1
- package/dist/services/streamingEncoder.js +0 -227
- package/dist/services/streamingEncoder.js.map +0 -1
- package/dist/services/videoFrameExtractor.d.ts +0 -65
- package/dist/services/videoFrameExtractor.d.ts.map +0 -1
- package/dist/services/videoFrameExtractor.js +0 -302
- package/dist/services/videoFrameExtractor.js.map +0 -1
- package/dist/services/videoFrameInjector.d.ts +0 -17
- package/dist/services/videoFrameInjector.d.ts.map +0 -1
- package/dist/services/videoFrameInjector.js +0 -92
- package/dist/services/videoFrameInjector.js.map +0 -1
- package/dist/types.d.ts +0 -79
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -8
- package/dist/types.js.map +0 -1
- package/dist/utils/ffprobe.d.ts +0 -18
- package/dist/utils/ffprobe.d.ts.map +0 -1
- package/dist/utils/ffprobe.js +0 -137
- package/dist/utils/ffprobe.js.map +0 -1
- package/dist/utils/gpuEncoder.d.ts +0 -11
- package/dist/utils/gpuEncoder.d.ts.map +0 -1
- package/dist/utils/gpuEncoder.js +0 -55
- package/dist/utils/gpuEncoder.js.map +0 -1
- package/dist/utils/parityContract.d.ts +0 -3
- package/dist/utils/parityContract.d.ts.map +0 -1
- package/dist/utils/parityContract.js +0 -35
- package/dist/utils/parityContract.js.map +0 -1
- package/dist/utils/runFfmpeg.d.ts +0 -19
- package/dist/utils/runFfmpeg.d.ts.map +0 -1
- package/dist/utils/runFfmpeg.js +0 -62
- package/dist/utils/runFfmpeg.js.map +0 -1
- package/dist/utils/urlDownloader.d.ts +0 -3
- package/dist/utils/urlDownloader.d.ts.map +0 -1
- package/dist/utils/urlDownloader.js +0 -68
- 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.
|
|
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(
|
|
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(
|
|
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(
|
|
148
|
-
|
|
149
|
-
|
|
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(
|
|
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(
|
|
162
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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:
|
|
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 {
|
|
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 {
|
|
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:
|
|
291
|
+
error:
|
|
292
|
+
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
|
|
276
293
|
};
|
|
277
294
|
}
|
|
278
|
-
return {
|
|
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(
|
|
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 =
|
|
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(
|
|
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<
|
|
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 = [
|
|
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 {
|
|
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 {
|
|
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(
|
|
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 {
|
|
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 =
|
|
76
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
351
|
-
|
|
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(
|
|
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(
|
|
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<
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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);
|