@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,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel Coordinator Service
|
|
3
|
+
*
|
|
4
|
+
* Coordinates parallel frame capture across multiple Puppeteer sessions.
|
|
5
|
+
* Auto-detects optimal worker count based on CPU/memory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cpus, freemem, totalmem } from "os";
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync } from "fs";
|
|
10
|
+
import { copyFile, rename } from "fs/promises";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createCaptureSession,
|
|
15
|
+
initializeSession,
|
|
16
|
+
closeCaptureSession,
|
|
17
|
+
captureFrame,
|
|
18
|
+
captureFrameToBuffer,
|
|
19
|
+
getCapturePerfSummary,
|
|
20
|
+
type CaptureSession,
|
|
21
|
+
type CaptureOptions,
|
|
22
|
+
type CapturePerfSummary,
|
|
23
|
+
type BeforeCaptureHook,
|
|
24
|
+
} from "./frameCapture.js";
|
|
25
|
+
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
|
26
|
+
|
|
27
|
+
export interface WorkerTask {
|
|
28
|
+
workerId: number;
|
|
29
|
+
startFrame: number;
|
|
30
|
+
endFrame: number;
|
|
31
|
+
outputDir: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkerResult {
|
|
35
|
+
workerId: number;
|
|
36
|
+
framesCaptured: number;
|
|
37
|
+
startFrame: number;
|
|
38
|
+
endFrame: number;
|
|
39
|
+
durationMs: number;
|
|
40
|
+
perf?: CapturePerfSummary;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ParallelProgress {
|
|
45
|
+
totalFrames: number;
|
|
46
|
+
capturedFrames: number;
|
|
47
|
+
activeWorkers: number;
|
|
48
|
+
workerProgress: Map<number, number>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MEMORY_PER_WORKER_MB = 256;
|
|
52
|
+
const MIN_WORKERS = 1;
|
|
53
|
+
const ABSOLUTE_MAX_WORKERS = 10;
|
|
54
|
+
const DEFAULT_SAFE_MAX_WORKERS = 6;
|
|
55
|
+
const MIN_FRAMES_PER_WORKER = 30;
|
|
56
|
+
|
|
57
|
+
export function calculateOptimalWorkers(
|
|
58
|
+
totalFrames: number,
|
|
59
|
+
requested?: number,
|
|
60
|
+
config?: Partial<Pick<EngineConfig, "concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold">>,
|
|
61
|
+
): number {
|
|
62
|
+
// Resolve effective values: config overrides → DEFAULT_CONFIG fallback.
|
|
63
|
+
const effectiveMaxWorkers = (() => {
|
|
64
|
+
const concurrency = config?.concurrency ?? DEFAULT_CONFIG.concurrency;
|
|
65
|
+
if (concurrency !== "auto") {
|
|
66
|
+
return Math.max(MIN_WORKERS, Math.min(ABSOLUTE_MAX_WORKERS, Math.floor(concurrency)));
|
|
67
|
+
}
|
|
68
|
+
return DEFAULT_SAFE_MAX_WORKERS;
|
|
69
|
+
})();
|
|
70
|
+
const effectiveCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker;
|
|
71
|
+
const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
|
|
72
|
+
const effectiveLargeRenderThreshold = config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
|
|
73
|
+
|
|
74
|
+
if (requested !== undefined) {
|
|
75
|
+
return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (totalFrames < MIN_FRAMES_PER_WORKER * 2) return 1;
|
|
79
|
+
|
|
80
|
+
const cpuCount = cpus().length;
|
|
81
|
+
const cpuBasedWorkers = Math.max(1, cpuCount - 2);
|
|
82
|
+
|
|
83
|
+
// Use total memory instead of free memory — macOS reports misleadingly low
|
|
84
|
+
// freemem() because it aggressively caches files in "inactive" memory that
|
|
85
|
+
// is immediately reclaimable.
|
|
86
|
+
const totalMemoryMB = Math.round(totalmem() / (1024 * 1024));
|
|
87
|
+
const memoryBasedWorkers = Math.max(1, Math.floor((totalMemoryMB * 0.5) / MEMORY_PER_WORKER_MB));
|
|
88
|
+
|
|
89
|
+
const frameBasedWorkers = Math.floor(totalFrames / MIN_FRAMES_PER_WORKER);
|
|
90
|
+
|
|
91
|
+
const optimal = Math.min(cpuBasedWorkers, memoryBasedWorkers, frameBasedWorkers);
|
|
92
|
+
const minWorkersForJob = totalFrames >= effectiveMinParallelFrames ? 2 : MIN_WORKERS;
|
|
93
|
+
let finalWorkers = Math.max(minWorkersForJob, Math.min(effectiveMaxWorkers, optimal));
|
|
94
|
+
|
|
95
|
+
// Adaptive scaling: cap workers for large renders to prevent CPU contention.
|
|
96
|
+
// Each Chrome process (with SwiftShader) is CPU-heavy; too many on a long
|
|
97
|
+
// render causes protocol timeouts from compositor starvation.
|
|
98
|
+
// Scale proportionally to CPU count: ~3 cores per worker (benchmarked).
|
|
99
|
+
// 8 cores → 2 workers, 16 cores → 5 workers, 32 cores → 10 workers.
|
|
100
|
+
if (totalFrames >= effectiveLargeRenderThreshold) {
|
|
101
|
+
const cpuScaledMax = Math.max(2, Math.floor(cpuCount / effectiveCoresPerWorker));
|
|
102
|
+
if (finalWorkers > cpuScaledMax) {
|
|
103
|
+
finalWorkers = cpuScaledMax;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return finalWorkers;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function distributeFrames(totalFrames: number, workerCount: number, workDir: string): WorkerTask[] {
|
|
111
|
+
const tasks: WorkerTask[] = [];
|
|
112
|
+
const framesPerWorker = Math.ceil(totalFrames / workerCount);
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < workerCount; i++) {
|
|
115
|
+
const startFrame = i * framesPerWorker;
|
|
116
|
+
const endFrame = Math.min((i + 1) * framesPerWorker, totalFrames);
|
|
117
|
+
if (startFrame >= totalFrames) break;
|
|
118
|
+
|
|
119
|
+
tasks.push({
|
|
120
|
+
workerId: i,
|
|
121
|
+
startFrame,
|
|
122
|
+
endFrame,
|
|
123
|
+
outputDir: join(workDir, `worker-${i}`),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return tasks;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function executeWorkerTask(
|
|
131
|
+
task: WorkerTask,
|
|
132
|
+
serverUrl: string,
|
|
133
|
+
captureOptions: CaptureOptions,
|
|
134
|
+
createBeforeCaptureHook: () => BeforeCaptureHook | null,
|
|
135
|
+
signal?: AbortSignal,
|
|
136
|
+
onFrameCaptured?: (workerId: number, frameIndex: number) => void,
|
|
137
|
+
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
|
|
138
|
+
config?: Partial<EngineConfig>,
|
|
139
|
+
): Promise<WorkerResult> {
|
|
140
|
+
const startTime = Date.now();
|
|
141
|
+
let framesCaptured = 0;
|
|
142
|
+
|
|
143
|
+
if (!existsSync(task.outputDir)) mkdirSync(task.outputDir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
let session: CaptureSession | null = null;
|
|
146
|
+
let perf: CapturePerfSummary | undefined;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
session = await createCaptureSession(serverUrl, task.outputDir, captureOptions, createBeforeCaptureHook(), config);
|
|
150
|
+
await initializeSession(session);
|
|
151
|
+
|
|
152
|
+
for (let i = task.startFrame; i < task.endFrame; i++) {
|
|
153
|
+
if (signal?.aborted) {
|
|
154
|
+
throw new Error("Parallel worker cancelled");
|
|
155
|
+
}
|
|
156
|
+
const time = i / captureOptions.fps;
|
|
157
|
+
|
|
158
|
+
if (onFrameBuffer) {
|
|
159
|
+
// Streaming mode: capture to buffer and invoke callback
|
|
160
|
+
const { buffer } = await captureFrameToBuffer(session, i, time);
|
|
161
|
+
await onFrameBuffer(i, buffer);
|
|
162
|
+
} else {
|
|
163
|
+
// Disk mode: capture to file
|
|
164
|
+
await captureFrame(session, i, time);
|
|
165
|
+
}
|
|
166
|
+
framesCaptured++;
|
|
167
|
+
|
|
168
|
+
if (onFrameCaptured) onFrameCaptured(task.workerId, i);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
perf = getCapturePerfSummary(session);
|
|
172
|
+
return {
|
|
173
|
+
workerId: task.workerId,
|
|
174
|
+
framesCaptured,
|
|
175
|
+
startFrame: task.startFrame,
|
|
176
|
+
endFrame: task.endFrame,
|
|
177
|
+
durationMs: Date.now() - startTime,
|
|
178
|
+
perf,
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
182
|
+
return {
|
|
183
|
+
workerId: task.workerId,
|
|
184
|
+
framesCaptured,
|
|
185
|
+
startFrame: task.startFrame,
|
|
186
|
+
endFrame: task.endFrame,
|
|
187
|
+
durationMs: Date.now() - startTime,
|
|
188
|
+
perf,
|
|
189
|
+
error: errMsg,
|
|
190
|
+
};
|
|
191
|
+
} finally {
|
|
192
|
+
if (session) await closeCaptureSession(session).catch(() => {});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function executeParallelCapture(
|
|
197
|
+
serverUrl: string,
|
|
198
|
+
workDir: string,
|
|
199
|
+
tasks: WorkerTask[],
|
|
200
|
+
captureOptions: CaptureOptions,
|
|
201
|
+
createBeforeCaptureHook: () => BeforeCaptureHook | null,
|
|
202
|
+
signal?: AbortSignal,
|
|
203
|
+
onProgress?: (progress: ParallelProgress) => void,
|
|
204
|
+
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
|
|
205
|
+
config?: Partial<EngineConfig>,
|
|
206
|
+
): Promise<WorkerResult[]> {
|
|
207
|
+
const totalFrames = tasks.reduce((sum, t) => sum + (t.endFrame - t.startFrame), 0);
|
|
208
|
+
const workerProgress = new Map<number, number>();
|
|
209
|
+
|
|
210
|
+
for (const task of tasks) workerProgress.set(task.workerId, 0);
|
|
211
|
+
|
|
212
|
+
const onFrameCaptured = (workerId: number, _frameIndex: number) => {
|
|
213
|
+
const current = workerProgress.get(workerId) || 0;
|
|
214
|
+
workerProgress.set(workerId, current + 1);
|
|
215
|
+
|
|
216
|
+
if (onProgress) {
|
|
217
|
+
const capturedFrames = Array.from(workerProgress.values()).reduce((a, b) => a + b, 0);
|
|
218
|
+
onProgress({
|
|
219
|
+
totalFrames,
|
|
220
|
+
capturedFrames,
|
|
221
|
+
activeWorkers: tasks.length,
|
|
222
|
+
workerProgress: new Map(workerProgress),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const results = await Promise.all(
|
|
228
|
+
tasks.map((task) =>
|
|
229
|
+
executeWorkerTask(
|
|
230
|
+
task,
|
|
231
|
+
serverUrl,
|
|
232
|
+
captureOptions,
|
|
233
|
+
createBeforeCaptureHook,
|
|
234
|
+
signal,
|
|
235
|
+
onFrameCaptured,
|
|
236
|
+
onFrameBuffer,
|
|
237
|
+
config,
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const errors = results.filter((r) => r.error);
|
|
243
|
+
if (errors.length > 0) {
|
|
244
|
+
const errorMessages = errors.map((e) => `Worker ${e.workerId}: ${e.error}`).join("; ");
|
|
245
|
+
throw new Error(`[Parallel] Capture failed: ${errorMessages}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return results;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function mergeWorkerFrames(workDir: string, tasks: WorkerTask[], outputDir: string): Promise<number> {
|
|
252
|
+
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
253
|
+
|
|
254
|
+
let totalFrames = 0;
|
|
255
|
+
const sortedTasks = [...tasks].sort((a, b) => a.startFrame - b.startFrame);
|
|
256
|
+
|
|
257
|
+
for (const task of sortedTasks) {
|
|
258
|
+
if (!existsSync(task.outputDir)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const files = readdirSync(task.outputDir)
|
|
263
|
+
.filter((f) => f.startsWith("frame_") && (f.endsWith(".jpg") || f.endsWith(".png")))
|
|
264
|
+
.sort();
|
|
265
|
+
const copyTasks = files.map(async (file) => {
|
|
266
|
+
const sourcePath = join(task.outputDir, file);
|
|
267
|
+
const targetPath = join(outputDir, file);
|
|
268
|
+
try {
|
|
269
|
+
await rename(sourcePath, targetPath);
|
|
270
|
+
} catch {
|
|
271
|
+
await copyFile(sourcePath, targetPath);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
await Promise.all(copyTasks);
|
|
275
|
+
totalFrames += files.length;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return totalFrames;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function getSystemResources(): {
|
|
282
|
+
cpuCores: number;
|
|
283
|
+
totalMemoryMB: number;
|
|
284
|
+
freeMemoryMB: number;
|
|
285
|
+
recommendedWorkers: number;
|
|
286
|
+
} {
|
|
287
|
+
return {
|
|
288
|
+
cpuCores: cpus().length,
|
|
289
|
+
totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
|
|
290
|
+
freeMemoryMB: Math.round(freemem() / (1024 * 1024)),
|
|
291
|
+
recommendedWorkers: calculateOptimalWorkers(1000),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot Service
|
|
3
|
+
*
|
|
4
|
+
* BeginFrame-based deterministic screenshot capture and video frame injection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type Page } from "puppeteer-core";
|
|
8
|
+
import { type CaptureOptions } from "../types.js";
|
|
9
|
+
import { MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
|
|
10
|
+
|
|
11
|
+
export const cdpSessionCache = new WeakMap<Page, import("puppeteer-core").CDPSession>();
|
|
12
|
+
|
|
13
|
+
export async function getCdpSession(page: Page): Promise<import("puppeteer-core").CDPSession> {
|
|
14
|
+
let client = cdpSessionCache.get(page);
|
|
15
|
+
if (!client) {
|
|
16
|
+
client = await page.createCDPSession();
|
|
17
|
+
cdpSessionCache.set(page, client);
|
|
18
|
+
}
|
|
19
|
+
return client;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* BeginFrame result with screenshot data and damage detection.
|
|
24
|
+
*/
|
|
25
|
+
export interface BeginFrameResult {
|
|
26
|
+
buffer: Buffer;
|
|
27
|
+
hasDamage: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Capture a frame using HeadlessExperimental.beginFrame.
|
|
32
|
+
*
|
|
33
|
+
* This is an atomic operation: one CDP call runs a single layout-paint-composite
|
|
34
|
+
* cycle and returns the screenshot + hasDamage boolean. Replaces the separate
|
|
35
|
+
* settle → screenshot pipeline with a single deterministic render cycle.
|
|
36
|
+
*
|
|
37
|
+
* Requires chrome-headless-shell with --enable-begin-frame-control and
|
|
38
|
+
* --deterministic-mode flags.
|
|
39
|
+
*/
|
|
40
|
+
// Cache the last valid screenshot buffer per page for hasDamage=false frames.
|
|
41
|
+
// When Chrome reports no visual change, we reuse the previous frame rather than
|
|
42
|
+
// attempting Page.captureScreenshot (which times out in beginFrame mode since
|
|
43
|
+
// the compositor is paused).
|
|
44
|
+
const lastFrameCache = new WeakMap<Page, Buffer>();
|
|
45
|
+
|
|
46
|
+
export async function beginFrameCapture(
|
|
47
|
+
page: Page,
|
|
48
|
+
options: CaptureOptions,
|
|
49
|
+
frameTimeTicks: number,
|
|
50
|
+
interval: number,
|
|
51
|
+
): Promise<BeginFrameResult> {
|
|
52
|
+
const client = await getCdpSession(page);
|
|
53
|
+
|
|
54
|
+
const format = options.format === "png" ? "png" : "jpeg";
|
|
55
|
+
const result = await client.send("HeadlessExperimental.beginFrame", {
|
|
56
|
+
frameTimeTicks,
|
|
57
|
+
interval,
|
|
58
|
+
screenshot: {
|
|
59
|
+
format,
|
|
60
|
+
quality: format === "jpeg" ? (options.quality ?? 80) : undefined,
|
|
61
|
+
optimizeForSpeed: true,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let buffer: Buffer;
|
|
66
|
+
if (result.screenshotData) {
|
|
67
|
+
buffer = Buffer.from(result.screenshotData, "base64");
|
|
68
|
+
lastFrameCache.set(page, buffer);
|
|
69
|
+
} else {
|
|
70
|
+
// hasDamage=false — nothing changed visually. Reuse the last frame.
|
|
71
|
+
const cached = lastFrameCache.get(page);
|
|
72
|
+
if (cached) {
|
|
73
|
+
buffer = cached;
|
|
74
|
+
} else {
|
|
75
|
+
// No cached frame yet (shouldn't happen — frame 0 always has damage).
|
|
76
|
+
// Issue another beginFrame with a tiny time advance to force a composite.
|
|
77
|
+
const retry = await client.send("HeadlessExperimental.beginFrame", {
|
|
78
|
+
frameTimeTicks: frameTimeTicks + 0.001,
|
|
79
|
+
interval,
|
|
80
|
+
screenshot: {
|
|
81
|
+
format,
|
|
82
|
+
quality: format === "jpeg" ? (options.quality ?? 80) : undefined,
|
|
83
|
+
optimizeForSpeed: true,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
buffer = retry.screenshotData ? Buffer.from(retry.screenshotData, "base64") : Buffer.alloc(0);
|
|
87
|
+
if (buffer.length > 0) lastFrameCache.set(page, buffer);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
buffer,
|
|
93
|
+
hasDamage: result.hasDamage,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Capture a screenshot using standard Page.captureScreenshot CDP call.
|
|
99
|
+
* Fallback for environments where BeginFrame is unavailable (macOS, Windows).
|
|
100
|
+
*/
|
|
101
|
+
export async function pageScreenshotCapture(page: Page, options: CaptureOptions): Promise<Buffer> {
|
|
102
|
+
const client = await getCdpSession(page);
|
|
103
|
+
const format = options.format === "png" ? "png" : "jpeg";
|
|
104
|
+
const result = await client.send("Page.captureScreenshot", {
|
|
105
|
+
format,
|
|
106
|
+
quality: format === "jpeg" ? (options.quality ?? 80) : undefined,
|
|
107
|
+
optimizeForSpeed: true,
|
|
108
|
+
});
|
|
109
|
+
return Buffer.from(result.data, "base64");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function injectVideoFramesBatch(
|
|
113
|
+
page: Page,
|
|
114
|
+
updates: Array<{ videoId: string; dataUri: string }>,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
if (updates.length === 0) return;
|
|
117
|
+
await page.evaluate(
|
|
118
|
+
async (items: Array<{ videoId: string; dataUri: string }>, visualProperties: string[]) => {
|
|
119
|
+
const pendingDecodes: Array<Promise<void>> = [];
|
|
120
|
+
for (const item of items) {
|
|
121
|
+
const video = document.getElementById(item.videoId) as HTMLVideoElement | null;
|
|
122
|
+
if (!video) continue;
|
|
123
|
+
|
|
124
|
+
let img = video.nextElementSibling as HTMLImageElement | null;
|
|
125
|
+
const isNewImage = !img || !img.classList.contains("__render_frame__");
|
|
126
|
+
const computedStyle = window.getComputedStyle(video);
|
|
127
|
+
const computedOpacity = parseFloat(computedStyle.opacity) || 1;
|
|
128
|
+
const sourceIsStatic = !computedStyle.position || computedStyle.position === "static";
|
|
129
|
+
|
|
130
|
+
if (isNewImage) {
|
|
131
|
+
img = document.createElement("img");
|
|
132
|
+
img.classList.add("__render_frame__");
|
|
133
|
+
img.id = `__render_frame_${item.videoId}__`;
|
|
134
|
+
img.style.pointerEvents = "none";
|
|
135
|
+
video.parentNode?.insertBefore(img, video.nextSibling);
|
|
136
|
+
}
|
|
137
|
+
if (!img) continue;
|
|
138
|
+
|
|
139
|
+
if (!sourceIsStatic) {
|
|
140
|
+
img.style.position = computedStyle.position;
|
|
141
|
+
img.style.width = computedStyle.width;
|
|
142
|
+
img.style.height = computedStyle.height;
|
|
143
|
+
img.style.top = computedStyle.top;
|
|
144
|
+
img.style.left = computedStyle.left;
|
|
145
|
+
img.style.right = computedStyle.right;
|
|
146
|
+
img.style.bottom = computedStyle.bottom;
|
|
147
|
+
img.style.inset = computedStyle.inset;
|
|
148
|
+
} else {
|
|
149
|
+
const videoRect = video.getBoundingClientRect();
|
|
150
|
+
const offsetLeft = Number.isFinite(video.offsetLeft) ? video.offsetLeft : 0;
|
|
151
|
+
const offsetTop = Number.isFinite(video.offsetTop) ? video.offsetTop : 0;
|
|
152
|
+
const offsetWidth = video.offsetWidth > 0 ? video.offsetWidth : videoRect.width;
|
|
153
|
+
const offsetHeight = video.offsetHeight > 0 ? video.offsetHeight : videoRect.height;
|
|
154
|
+
img.style.position = "absolute";
|
|
155
|
+
img.style.inset = "auto";
|
|
156
|
+
img.style.left = `${offsetLeft}px`;
|
|
157
|
+
img.style.top = `${offsetTop}px`;
|
|
158
|
+
img.style.right = "auto";
|
|
159
|
+
img.style.bottom = "auto";
|
|
160
|
+
img.style.width = `${offsetWidth}px`;
|
|
161
|
+
img.style.height = `${offsetHeight}px`;
|
|
162
|
+
}
|
|
163
|
+
img.style.objectFit = computedStyle.objectFit;
|
|
164
|
+
img.style.objectPosition = computedStyle.objectPosition;
|
|
165
|
+
img.style.zIndex = computedStyle.zIndex;
|
|
166
|
+
|
|
167
|
+
for (const property of visualProperties) {
|
|
168
|
+
if (
|
|
169
|
+
sourceIsStatic &&
|
|
170
|
+
(property === "top" ||
|
|
171
|
+
property === "left" ||
|
|
172
|
+
property === "right" ||
|
|
173
|
+
property === "bottom" ||
|
|
174
|
+
property === "inset")
|
|
175
|
+
) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const value = computedStyle.getPropertyValue(property);
|
|
179
|
+
if (value) {
|
|
180
|
+
img.style.setProperty(property, value);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
img.decoding = "sync";
|
|
184
|
+
img.src = item.dataUri;
|
|
185
|
+
pendingDecodes.push(
|
|
186
|
+
img
|
|
187
|
+
.decode()
|
|
188
|
+
.catch(() => undefined)
|
|
189
|
+
.then(() => undefined),
|
|
190
|
+
);
|
|
191
|
+
img.style.opacity = String(computedOpacity);
|
|
192
|
+
img.style.visibility = "visible";
|
|
193
|
+
video.style.setProperty("visibility", "hidden", "important");
|
|
194
|
+
video.style.setProperty("opacity", "0", "important");
|
|
195
|
+
video.style.setProperty("pointer-events", "none", "important");
|
|
196
|
+
}
|
|
197
|
+
if (pendingDecodes.length > 0) {
|
|
198
|
+
await Promise.all(pendingDecodes);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
updates,
|
|
202
|
+
[...MEDIA_VISUAL_STYLE_PROPERTIES],
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function syncVideoFrameVisibility(page: Page, activeVideoIds: string[]): Promise<void> {
|
|
207
|
+
await page.evaluate((ids: string[]) => {
|
|
208
|
+
const active = new Set(ids);
|
|
209
|
+
const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
|
|
210
|
+
for (const video of videos) {
|
|
211
|
+
if (active.has(video.id)) continue;
|
|
212
|
+
video.style.removeProperty("display");
|
|
213
|
+
video.style.setProperty("visibility", "hidden", "important");
|
|
214
|
+
video.style.setProperty("opacity", "0", "important");
|
|
215
|
+
video.style.setProperty("pointer-events", "none", "important");
|
|
216
|
+
const img = video.nextElementSibling as HTMLElement | null;
|
|
217
|
+
if (img && img.classList.contains("__render_frame__")) {
|
|
218
|
+
img.style.visibility = "hidden";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}, activeVideoIds);
|
|
222
|
+
}
|