@hyperframes/engine 0.1.1 → 0.1.3
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/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/services/audioMixer.d.ts.map +1 -1
- package/dist/services/audioMixer.js +26 -5
- package/dist/services/audioMixer.js.map +1 -1
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +24 -3
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/fileServer.d.ts.map +1 -1
- package/dist/services/fileServer.js.map +1 -1
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +1 -1
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/parallelCoordinator.d.ts.map +1 -1
- package/dist/services/parallelCoordinator.js.map +1 -1
- package/dist/services/screenshotService.d.ts.map +1 -1
- package/dist/services/screenshotService.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +7 -1
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +3 -1
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +18 -2
- package/dist/utils/ffprobe.js.map +1 -1
- package/dist/utils/urlDownloader.d.ts.map +1 -1
- package/dist/utils/urlDownloader.js.map +1 -1
- package/package.json +2 -2
- 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
|
@@ -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);
|
|
@@ -71,7 +71,10 @@ export function createVideoFrameInjector(
|
|
|
71
71
|
): BeforeCaptureHook | null {
|
|
72
72
|
if (!frameLookup) return null;
|
|
73
73
|
|
|
74
|
-
const cacheLimit = Math.max(
|
|
74
|
+
const cacheLimit = Math.max(
|
|
75
|
+
32,
|
|
76
|
+
config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit,
|
|
77
|
+
);
|
|
75
78
|
const frameCache = createFrameDataUriCache(cacheLimit);
|
|
76
79
|
const lastInjectedFrameByVideo = new Map<string, number>();
|
|
77
80
|
|
|
@@ -81,13 +84,16 @@ export function createVideoFrameInjector(
|
|
|
81
84
|
const updates: Array<{ videoId: string; dataUri: string; frameIndex: number }> = [];
|
|
82
85
|
const activeIds = new Set<string>();
|
|
83
86
|
if (activePayloads.size > 0) {
|
|
84
|
-
const pendingReads: Array<Promise<{ videoId: string; dataUri: string; frameIndex: number }>> =
|
|
87
|
+
const pendingReads: Array<Promise<{ videoId: string; dataUri: string; frameIndex: number }>> =
|
|
88
|
+
[];
|
|
85
89
|
for (const [videoId, payload] of activePayloads) {
|
|
86
90
|
activeIds.add(videoId);
|
|
87
91
|
const lastFrameIndex = lastInjectedFrameByVideo.get(videoId);
|
|
88
92
|
if (lastFrameIndex === payload.frameIndex) continue;
|
|
89
93
|
pendingReads.push(
|
|
90
|
-
frameCache
|
|
94
|
+
frameCache
|
|
95
|
+
.get(payload.framePath)
|
|
96
|
+
.then((dataUri) => ({ videoId, dataUri, frameIndex: payload.frameIndex })),
|
|
91
97
|
);
|
|
92
98
|
}
|
|
93
99
|
updates.push(...(await Promise.all(pendingReads)));
|
package/src/utils/ffprobe.ts
CHANGED
|
@@ -59,7 +59,15 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const probePromise = new Promise<VideoMetadata>((resolve, reject) => {
|
|
62
|
-
const args = [
|
|
62
|
+
const args = [
|
|
63
|
+
"-v",
|
|
64
|
+
"quiet",
|
|
65
|
+
"-print_format",
|
|
66
|
+
"json",
|
|
67
|
+
"-show_format",
|
|
68
|
+
"-show_streams",
|
|
69
|
+
filePath,
|
|
70
|
+
];
|
|
63
71
|
|
|
64
72
|
const ffprobe = spawn("ffprobe", args);
|
|
65
73
|
let stdout = "";
|
|
@@ -87,7 +95,8 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
const hasAudio = output.streams.some((s) => s.codec_type === "audio");
|
|
90
|
-
const fps =
|
|
98
|
+
const fps =
|
|
99
|
+
parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
|
|
91
100
|
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
|
92
101
|
|
|
93
102
|
const metadata: VideoMetadata = {
|
|
@@ -100,7 +109,11 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
|
|
100
109
|
};
|
|
101
110
|
resolve(metadata);
|
|
102
111
|
} catch (parseError: unknown) {
|
|
103
|
-
reject(
|
|
112
|
+
reject(
|
|
113
|
+
new Error(
|
|
114
|
+
`[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
104
117
|
}
|
|
105
118
|
});
|
|
106
119
|
|
|
@@ -128,7 +141,15 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
|
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
const probePromise = new Promise<AudioMetadata>((resolve, reject) => {
|
|
131
|
-
const args = [
|
|
144
|
+
const args = [
|
|
145
|
+
"-v",
|
|
146
|
+
"quiet",
|
|
147
|
+
"-print_format",
|
|
148
|
+
"json",
|
|
149
|
+
"-show_format",
|
|
150
|
+
"-show_streams",
|
|
151
|
+
filePath,
|
|
152
|
+
];
|
|
132
153
|
|
|
133
154
|
const ffprobe = spawn("ffprobe", args);
|
|
134
155
|
let stdout = "";
|
|
@@ -14,7 +14,11 @@ function getFilenameFromUrl(url: string): string {
|
|
|
14
14
|
return `download_${hash}${ext}`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export async function downloadToTemp(
|
|
17
|
+
export async function downloadToTemp(
|
|
18
|
+
url: string,
|
|
19
|
+
destDir: string,
|
|
20
|
+
timeoutMs: number = 300000,
|
|
21
|
+
): Promise<string> {
|
|
18
22
|
const cachedPath = downloadPathCache.get(url);
|
|
19
23
|
if (cachedPath && existsSync(cachedPath)) {
|
|
20
24
|
return cachedPath;
|