@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.
Files changed (119) hide show
  1. package/dist/config.d.ts +62 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +92 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/config.test.d.ts +2 -0
  6. package/dist/config.test.d.ts.map +1 -0
  7. package/dist/config.test.js +77 -0
  8. package/dist/config.test.js.map +1 -0
  9. package/dist/index.d.ts +49 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +55 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/services/audioMixer.d.ts +11 -0
  14. package/dist/services/audioMixer.d.ts.map +1 -0
  15. package/dist/services/audioMixer.js +306 -0
  16. package/dist/services/audioMixer.js.map +1 -0
  17. package/dist/services/audioMixer.types.d.ts +27 -0
  18. package/dist/services/audioMixer.types.d.ts.map +1 -0
  19. package/dist/services/audioMixer.types.js +2 -0
  20. package/dist/services/audioMixer.types.js.map +1 -0
  21. package/dist/services/browserManager.d.ts +29 -0
  22. package/dist/services/browserManager.d.ts.map +1 -0
  23. package/dist/services/browserManager.js +175 -0
  24. package/dist/services/browserManager.js.map +1 -0
  25. package/dist/services/chunkEncoder.d.ts +32 -0
  26. package/dist/services/chunkEncoder.d.ts.map +1 -0
  27. package/dist/services/chunkEncoder.js +347 -0
  28. package/dist/services/chunkEncoder.js.map +1 -0
  29. package/dist/services/chunkEncoder.test.d.ts +2 -0
  30. package/dist/services/chunkEncoder.test.d.ts.map +1 -0
  31. package/dist/services/chunkEncoder.test.js +24 -0
  32. package/dist/services/chunkEncoder.test.js.map +1 -0
  33. package/dist/services/chunkEncoder.types.d.ts +26 -0
  34. package/dist/services/chunkEncoder.types.d.ts.map +1 -0
  35. package/dist/services/chunkEncoder.types.js +2 -0
  36. package/dist/services/chunkEncoder.types.js.map +1 -0
  37. package/dist/services/fileServer.d.ts +25 -0
  38. package/dist/services/fileServer.d.ts.map +1 -0
  39. package/dist/services/fileServer.js +138 -0
  40. package/dist/services/fileServer.js.map +1 -0
  41. package/dist/services/frameCapture.d.ts +52 -0
  42. package/dist/services/frameCapture.d.ts.map +1 -0
  43. package/dist/services/frameCapture.js +317 -0
  44. package/dist/services/frameCapture.js.map +1 -0
  45. package/dist/services/parallelCoordinator.d.ts +40 -0
  46. package/dist/services/parallelCoordinator.d.ts.map +1 -0
  47. package/dist/services/parallelCoordinator.js +192 -0
  48. package/dist/services/parallelCoordinator.js.map +1 -0
  49. package/dist/services/parallelCoordinator.test.d.ts +2 -0
  50. package/dist/services/parallelCoordinator.test.d.ts.map +1 -0
  51. package/dist/services/parallelCoordinator.test.js +38 -0
  52. package/dist/services/parallelCoordinator.test.js.map +1 -0
  53. package/dist/services/screenshotService.d.ts +28 -0
  54. package/dist/services/screenshotService.d.ts.map +1 -0
  55. package/dist/services/screenshotService.js +190 -0
  56. package/dist/services/screenshotService.js.map +1 -0
  57. package/dist/services/streamingEncoder.d.ts +49 -0
  58. package/dist/services/streamingEncoder.d.ts.map +1 -0
  59. package/dist/services/streamingEncoder.js +227 -0
  60. package/dist/services/streamingEncoder.js.map +1 -0
  61. package/dist/services/videoFrameExtractor.d.ts +65 -0
  62. package/dist/services/videoFrameExtractor.d.ts.map +1 -0
  63. package/dist/services/videoFrameExtractor.js +302 -0
  64. package/dist/services/videoFrameExtractor.js.map +1 -0
  65. package/dist/services/videoFrameInjector.d.ts +17 -0
  66. package/dist/services/videoFrameInjector.d.ts.map +1 -0
  67. package/dist/services/videoFrameInjector.js +92 -0
  68. package/dist/services/videoFrameInjector.js.map +1 -0
  69. package/dist/types.d.ts +79 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +8 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/utils/ffprobe.d.ts +18 -0
  74. package/dist/utils/ffprobe.d.ts.map +1 -0
  75. package/dist/utils/ffprobe.js +137 -0
  76. package/dist/utils/ffprobe.js.map +1 -0
  77. package/dist/utils/gpuEncoder.d.ts +11 -0
  78. package/dist/utils/gpuEncoder.d.ts.map +1 -0
  79. package/dist/utils/gpuEncoder.js +55 -0
  80. package/dist/utils/gpuEncoder.js.map +1 -0
  81. package/dist/utils/parityContract.d.ts +3 -0
  82. package/dist/utils/parityContract.d.ts.map +1 -0
  83. package/dist/utils/parityContract.js +35 -0
  84. package/dist/utils/parityContract.js.map +1 -0
  85. package/dist/utils/runFfmpeg.d.ts +19 -0
  86. package/dist/utils/runFfmpeg.d.ts.map +1 -0
  87. package/dist/utils/runFfmpeg.js +62 -0
  88. package/dist/utils/runFfmpeg.js.map +1 -0
  89. package/dist/utils/urlDownloader.d.ts +3 -0
  90. package/dist/utils/urlDownloader.d.ts.map +1 -0
  91. package/dist/utils/urlDownloader.js +68 -0
  92. package/dist/utils/urlDownloader.js.map +1 -0
  93. package/package.json +30 -0
  94. package/src/cdp-headless-experimental.d.ts +54 -0
  95. package/src/config.test.ts +95 -0
  96. package/src/config.ts +176 -0
  97. package/src/index.ts +154 -0
  98. package/src/services/audioMixer.ts +392 -0
  99. package/src/services/audioMixer.types.ts +28 -0
  100. package/src/services/browserManager.ts +219 -0
  101. package/src/services/chunkEncoder.test.ts +27 -0
  102. package/src/services/chunkEncoder.ts +399 -0
  103. package/src/services/chunkEncoder.types.ts +27 -0
  104. package/src/services/fileServer.ts +175 -0
  105. package/src/services/frameCapture.ts +452 -0
  106. package/src/services/parallelCoordinator.test.ts +44 -0
  107. package/src/services/parallelCoordinator.ts +293 -0
  108. package/src/services/screenshotService.ts +222 -0
  109. package/src/services/streamingEncoder.ts +308 -0
  110. package/src/services/videoFrameExtractor.ts +395 -0
  111. package/src/services/videoFrameInjector.ts +113 -0
  112. package/src/types.ts +91 -0
  113. package/src/utils/ffprobe.ts +192 -0
  114. package/src/utils/gpuEncoder.ts +58 -0
  115. package/src/utils/parityContract.ts +35 -0
  116. package/src/utils/runFfmpeg.ts +81 -0
  117. package/src/utils/urlDownloader.ts +78 -0
  118. package/tsconfig.json +18 -0
  119. package/vitest.config.ts +7 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Streaming Encoder Service
3
+ *
4
+ * Pipes frame screenshot buffers directly to FFmpeg's stdin instead of writing
5
+ * them to disk and reading them back in a separate encode stage. Follows the
6
+ * Remotion pattern of image2pipe → FFmpeg.
7
+ *
8
+ * Two building blocks:
9
+ * 1. Frame reorder buffer – ensures out-of-order parallel workers feed
10
+ * frames to FFmpeg stdin in sequential order.
11
+ * 2. Streaming FFmpeg encoder – spawns FFmpeg with `-f image2pipe` and
12
+ * exposes a `writeFrame(buffer)` + `close()` API.
13
+ */
14
+
15
+ import { spawn, type ChildProcess } from "child_process";
16
+ import { existsSync, mkdirSync, statSync } from "fs";
17
+ import { dirname } from "path";
18
+
19
+ import { type GpuEncoder, getCachedGpuEncoder, getGpuEncoderName } from "../utils/gpuEncoder.js";
20
+ import { type EncoderOptions } from "./chunkEncoder.types.js";
21
+ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
22
+
23
+ // Re-export EncoderOptions so callers can reference the type via this module.
24
+ export type { EncoderOptions } from "./chunkEncoder.types.js";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // 1. Frame reorder buffer (based on Remotion's ensure-frames-in-order.ts)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface FrameReorderBuffer {
31
+ waitForFrame: (frame: number) => Promise<void>;
32
+ advanceTo: (frame: number) => void;
33
+ waitForAllDone: () => Promise<void>;
34
+ }
35
+
36
+ export function createFrameReorderBuffer(startFrame: number, endFrame: number): FrameReorderBuffer {
37
+ let nextFrame = startFrame;
38
+ let waiters: Array<{ frame: number; resolve: () => void }> = [];
39
+
40
+ const resolveWaiters = () => {
41
+ for (const waiter of waiters.slice()) {
42
+ if (waiter.frame === nextFrame) {
43
+ waiter.resolve();
44
+ waiters = waiters.filter((w) => w !== waiter);
45
+ }
46
+ }
47
+ };
48
+
49
+ return {
50
+ waitForFrame: (frame: number) =>
51
+ new Promise<void>((resolve) => {
52
+ waiters.push({ frame, resolve });
53
+ resolveWaiters();
54
+ }),
55
+ advanceTo: (frame: number) => {
56
+ nextFrame = frame;
57
+ resolveWaiters();
58
+ },
59
+ waitForAllDone: () =>
60
+ new Promise<void>((resolve) => {
61
+ waiters.push({ frame: endFrame, resolve });
62
+ resolveWaiters();
63
+ }),
64
+ };
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // 2. Streaming FFmpeg encoder
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export interface StreamingEncoderOptions {
72
+ fps: number;
73
+ width: number;
74
+ height: number;
75
+ codec?: "h264" | "h265" | "vp9" | "prores";
76
+ preset?: string;
77
+ quality?: number;
78
+ bitrate?: string;
79
+ pixelFormat?: string;
80
+ useGpu?: boolean;
81
+ imageFormat?: "jpeg" | "png";
82
+ }
83
+
84
+ export interface StreamingEncoderResult {
85
+ success: boolean;
86
+ durationMs: number;
87
+ fileSize: number;
88
+ error?: string;
89
+ }
90
+
91
+ export interface StreamingEncoder {
92
+ writeFrame: (buffer: Buffer) => boolean;
93
+ close: () => Promise<StreamingEncoderResult>;
94
+ getExitStatus: () => "running" | "success" | "error";
95
+ }
96
+
97
+ /**
98
+ * Build FFmpeg args for streaming (image2pipe) input.
99
+ * Reuses the same codec/quality/GPU logic as chunkEncoder's buildEncoderArgs
100
+ * but with `-f image2pipe` instead of `-i <pattern>`.
101
+ */
102
+ function buildStreamingArgs(
103
+ options: StreamingEncoderOptions,
104
+ outputPath: string,
105
+ gpuEncoder: GpuEncoder = null,
106
+ ): string[] {
107
+ const {
108
+ fps,
109
+ codec = "h264",
110
+ preset = "medium",
111
+ quality = 23,
112
+ bitrate,
113
+ pixelFormat = "yuv420p",
114
+ useGpu = false,
115
+ imageFormat = "jpeg",
116
+ } = options;
117
+
118
+ // Input args: pipe from stdin
119
+ const inputCodec = imageFormat === "png" ? "png" : "mjpeg";
120
+ const args: string[] = [
121
+ "-f",
122
+ "image2pipe",
123
+ "-vcodec",
124
+ inputCodec,
125
+ "-framerate",
126
+ String(fps),
127
+ "-i",
128
+ "-",
129
+ "-r",
130
+ String(fps),
131
+ ];
132
+
133
+ const shouldUseGpu = useGpu && gpuEncoder !== null;
134
+
135
+ if (codec === "h264" || codec === "h265") {
136
+ if (shouldUseGpu) {
137
+ const encoderName = getGpuEncoderName(gpuEncoder, codec);
138
+ args.push("-c:v", encoderName);
139
+
140
+ switch (gpuEncoder) {
141
+ case "nvenc":
142
+ args.push("-preset", preset);
143
+ if (bitrate) args.push("-b:v", bitrate);
144
+ else args.push("-cq", String(quality));
145
+ break;
146
+ case "videotoolbox":
147
+ if (bitrate) args.push("-b:v", bitrate);
148
+ else {
149
+ const vtQuality = Math.max(0, Math.min(100, 100 - quality * 2));
150
+ args.push("-q:v", String(vtQuality));
151
+ }
152
+ args.push("-allow_sw", "1");
153
+ break;
154
+ case "vaapi":
155
+ args.unshift("-vaapi_device", "/dev/dri/renderD128");
156
+ args.push("-vf", "format=nv12,hwupload");
157
+ if (bitrate) args.push("-b:v", bitrate);
158
+ else args.push("-qp", String(quality));
159
+ break;
160
+ case "qsv":
161
+ args.push("-preset", preset);
162
+ if (bitrate) args.push("-b:v", bitrate);
163
+ else args.push("-global_quality", String(quality));
164
+ break;
165
+ }
166
+ } else {
167
+ const encoderName = codec === "h264" ? "libx264" : "libx265";
168
+ args.push("-c:v", encoderName, "-preset", preset);
169
+ if (bitrate) args.push("-b:v", bitrate);
170
+ else args.push("-crf", String(quality));
171
+ }
172
+ } else if (codec === "vp9") {
173
+ args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));
174
+ args.push("-deadline", preset === "ultrafast" ? "realtime" : "good");
175
+ } else if (codec === "prores") {
176
+ args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0");
177
+ return [...args, "-y", outputPath];
178
+ }
179
+
180
+ if (gpuEncoder !== "vaapi") {
181
+ args.push("-pix_fmt", pixelFormat);
182
+ }
183
+
184
+ args.push("-y", outputPath);
185
+ return args;
186
+ }
187
+
188
+ /**
189
+ * Spawn a streaming FFmpeg encoder that accepts frame buffers on stdin.
190
+ */
191
+ export async function spawnStreamingEncoder(
192
+ outputPath: string,
193
+ options: StreamingEncoderOptions,
194
+ signal?: AbortSignal,
195
+ config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>,
196
+ ): Promise<StreamingEncoder> {
197
+ const outputDir = dirname(outputPath);
198
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
199
+
200
+ let gpuEncoder: GpuEncoder = null;
201
+ if (options.useGpu) {
202
+ gpuEncoder = await getCachedGpuEncoder();
203
+ }
204
+
205
+ const args = buildStreamingArgs(options, outputPath, gpuEncoder);
206
+
207
+ const startTime = Date.now();
208
+ const ffmpeg: ChildProcess = spawn("ffmpeg", args, {
209
+ stdio: ["pipe", "pipe", "pipe"],
210
+ });
211
+
212
+ let exitStatus: "running" | "success" | "error" = "running";
213
+ let stderr = "";
214
+ let exitCode: number | null = null;
215
+ let exitPromiseResolve: ((value: void) => void) | null = null;
216
+ const exitPromise = new Promise<void>((resolve) => (exitPromiseResolve = resolve));
217
+
218
+ // Track stderr for progress and error messages
219
+ ffmpeg.stderr?.on("data", (data: Buffer) => {
220
+ stderr += data.toString();
221
+ });
222
+
223
+ ffmpeg.on("close", (code: number | null) => {
224
+ exitCode = code;
225
+ exitStatus = code === 0 ? "success" : "error";
226
+ exitPromiseResolve?.();
227
+ });
228
+
229
+ ffmpeg.on("error", (err: Error) => {
230
+ exitStatus = "error";
231
+ stderr += `\nProcess error: ${err.message}`;
232
+ exitPromiseResolve?.();
233
+ });
234
+
235
+ // Handle abort signal
236
+ const onAbort = () => {
237
+ if (exitStatus === "running") {
238
+ ffmpeg.kill("SIGTERM");
239
+ }
240
+ };
241
+ if (signal) {
242
+ if (signal.aborted) {
243
+ ffmpeg.kill("SIGTERM");
244
+ } else {
245
+ signal.addEventListener("abort", onAbort, { once: true });
246
+ }
247
+ }
248
+
249
+ // Timeout safety
250
+ const streamingTimeout = config?.ffmpegStreamingTimeout ?? DEFAULT_CONFIG.ffmpegStreamingTimeout;
251
+ const timer = setTimeout(() => {
252
+ if (exitStatus === "running") {
253
+ ffmpeg.kill("SIGTERM");
254
+ }
255
+ }, streamingTimeout);
256
+
257
+ const encoder: StreamingEncoder = {
258
+ writeFrame: (buffer: Buffer): boolean => {
259
+ if (exitStatus !== "running" || !ffmpeg.stdin || ffmpeg.stdin.destroyed) {
260
+ return false;
261
+ }
262
+ return ffmpeg.stdin.write(buffer);
263
+ },
264
+
265
+ close: async (): Promise<StreamingEncoderResult> => {
266
+ clearTimeout(timer);
267
+ if (signal) signal.removeEventListener("abort", onAbort);
268
+
269
+ // Close stdin to signal end of input
270
+ if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
271
+ await new Promise<void>((resolve) => {
272
+ ffmpeg.stdin!.end(() => resolve());
273
+ });
274
+ }
275
+
276
+ // Wait for FFmpeg to finish
277
+ await exitPromise;
278
+
279
+ const durationMs = Date.now() - startTime;
280
+
281
+ if (signal?.aborted) {
282
+ return {
283
+ success: false,
284
+ durationMs,
285
+ fileSize: 0,
286
+ error: "Streaming encode cancelled",
287
+ };
288
+ }
289
+
290
+ if (exitCode !== 0) {
291
+ return {
292
+ success: false,
293
+ durationMs,
294
+ fileSize: 0,
295
+ error: `FFmpeg exited with code ${exitCode}`,
296
+ };
297
+ }
298
+
299
+ const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
300
+
301
+ return { success: true, durationMs, fileSize };
302
+ },
303
+
304
+ getExitStatus: () => exitStatus,
305
+ };
306
+
307
+ return encoder;
308
+ }
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Video Frame Extractor Service
3
+ *
4
+ * Pre-extracts video frames using FFmpeg for frame-accurate rendering.
5
+ * Videos are replaced with <img> elements during capture.
6
+ */
7
+
8
+ import { spawn } from "child_process";
9
+ import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
10
+ import { join } from "path";
11
+ import { parseHTML } from "linkedom";
12
+ import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
13
+ import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
14
+ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
15
+
16
+ export interface VideoElement {
17
+ id: string;
18
+ src: string;
19
+ start: number;
20
+ end: number;
21
+ mediaStart: number;
22
+ hasAudio: boolean;
23
+ }
24
+
25
+ export interface ExtractedFrames {
26
+ videoId: string;
27
+ srcPath: string;
28
+ outputDir: string;
29
+ framePattern: string;
30
+ fps: number;
31
+ totalFrames: number;
32
+ metadata: VideoMetadata;
33
+ framePaths: Map<number, string>;
34
+ }
35
+
36
+ export interface ExtractionOptions {
37
+ fps: number;
38
+ outputDir: string;
39
+ quality?: number;
40
+ format?: "jpg" | "png";
41
+ }
42
+
43
+ export interface ExtractionResult {
44
+ success: boolean;
45
+ extracted: ExtractedFrames[];
46
+ errors: Array<{ videoId: string; error: string }>;
47
+ totalFramesExtracted: number;
48
+ durationMs: number;
49
+ }
50
+
51
+ export function parseVideoElements(html: string): VideoElement[] {
52
+ const videos: VideoElement[] = [];
53
+ const { document } = parseHTML(html);
54
+
55
+ const videoEls = document.querySelectorAll("video[id][src]");
56
+ for (const el of videoEls) {
57
+ const id = el.getAttribute("id");
58
+ const src = el.getAttribute("src");
59
+ if (!id || !src) continue;
60
+
61
+ const startAttr = el.getAttribute("data-start");
62
+ const endAttr = el.getAttribute("data-end");
63
+ const mediaStartAttr = el.getAttribute("data-media-start");
64
+ const hasAudioAttr = el.getAttribute("data-has-audio");
65
+
66
+ videos.push({
67
+ id,
68
+ src,
69
+ start: startAttr ? parseFloat(startAttr) : 0,
70
+ end: endAttr ? parseFloat(endAttr) : 0,
71
+ mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
72
+ hasAudio: hasAudioAttr === "true",
73
+ });
74
+ }
75
+
76
+ return videos;
77
+ }
78
+
79
+ export async function extractVideoFramesRange(
80
+ videoPath: string,
81
+ videoId: string,
82
+ startTime: number,
83
+ duration: number,
84
+ options: ExtractionOptions,
85
+ signal?: AbortSignal,
86
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
87
+ ): Promise<ExtractedFrames> {
88
+ const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
89
+ const { fps, outputDir, quality = 95, format = "jpg" } = options;
90
+
91
+ const videoOutputDir = join(outputDir, videoId);
92
+ if (!existsSync(videoOutputDir)) mkdirSync(videoOutputDir, { recursive: true });
93
+
94
+ const metadata = await extractVideoMetadata(videoPath);
95
+ const framePattern = `frame_%05d.${format}`;
96
+ const outputPattern = join(videoOutputDir, framePattern);
97
+
98
+ const args: string[] = [
99
+ "-ss",
100
+ String(startTime),
101
+ "-i",
102
+ videoPath,
103
+ "-t",
104
+ String(duration),
105
+ "-vf",
106
+ `fps=${fps}`,
107
+ "-q:v",
108
+ format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0",
109
+ ];
110
+ if (format === "png") args.push("-compression_level", "6");
111
+ args.push("-y", outputPattern);
112
+
113
+ return new Promise((resolve, reject) => {
114
+ const ffmpeg = spawn("ffmpeg", args);
115
+ let stderr = "";
116
+ const onAbort = () => {
117
+ ffmpeg.kill("SIGTERM");
118
+ };
119
+ if (signal) {
120
+ if (signal.aborted) {
121
+ ffmpeg.kill("SIGTERM");
122
+ } else {
123
+ signal.addEventListener("abort", onAbort, { once: true });
124
+ }
125
+ }
126
+
127
+ const timer = setTimeout(() => {
128
+ ffmpeg.kill("SIGTERM");
129
+ }, ffmpegProcessTimeout);
130
+
131
+ ffmpeg.stderr.on("data", (data) => {
132
+ stderr += data.toString();
133
+ });
134
+
135
+ ffmpeg.on("close", (code) => {
136
+ clearTimeout(timer);
137
+ if (signal) signal.removeEventListener("abort", onAbort);
138
+ if (signal?.aborted) {
139
+ reject(new Error("Video frame extraction cancelled"));
140
+ return;
141
+ }
142
+ if (code !== 0) {
143
+ reject(new Error(`FFmpeg exited with code ${code}: ${stderr.slice(-500)}`));
144
+ return;
145
+ }
146
+
147
+ const framePaths = new Map<number, string>();
148
+ const files = readdirSync(videoOutputDir)
149
+ .filter((f) => f.startsWith("frame_") && f.endsWith(`.${format}`))
150
+ .sort();
151
+ files.forEach((file, index) => {
152
+ framePaths.set(index, join(videoOutputDir, file));
153
+ });
154
+
155
+ resolve({
156
+ videoId,
157
+ srcPath: videoPath,
158
+ outputDir: videoOutputDir,
159
+ framePattern,
160
+ fps,
161
+ totalFrames: framePaths.size,
162
+ metadata,
163
+ framePaths,
164
+ });
165
+ });
166
+
167
+ ffmpeg.on("error", (err) => {
168
+ clearTimeout(timer);
169
+ if (signal) signal.removeEventListener("abort", onAbort);
170
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
171
+ reject(new Error("[FFmpeg] ffmpeg not found"));
172
+ } else {
173
+ reject(err);
174
+ }
175
+ });
176
+ });
177
+ }
178
+
179
+ export async function extractAllVideoFrames(
180
+ videos: VideoElement[],
181
+ baseDir: string,
182
+ options: ExtractionOptions,
183
+ signal?: AbortSignal,
184
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
185
+ ): Promise<ExtractionResult> {
186
+ const startTime = Date.now();
187
+ const extracted: ExtractedFrames[] = [];
188
+ const errors: Array<{ videoId: string; error: string }> = [];
189
+ let totalFramesExtracted = 0;
190
+
191
+ // Process videos in parallel for better performance
192
+ const results = await Promise.all(
193
+ videos.map(async (video) => {
194
+ if (signal?.aborted) {
195
+ throw new Error("Video frame extraction cancelled");
196
+ }
197
+ try {
198
+ let videoPath = video.src;
199
+ if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
200
+ videoPath = join(baseDir, videoPath);
201
+ }
202
+
203
+ if (isHttpUrl(videoPath)) {
204
+ const downloadDir = join(options.outputDir, "_downloads");
205
+ mkdirSync(downloadDir, { recursive: true });
206
+ videoPath = await downloadToTemp(videoPath, downloadDir);
207
+ }
208
+
209
+ if (!existsSync(videoPath)) {
210
+ return { error: { videoId: video.id, error: `Video file not found: ${videoPath}` } };
211
+ }
212
+
213
+ let videoDuration = video.end - video.start;
214
+
215
+ // Fallback: if no data-duration/data-end was specified, probe the actual file
216
+ if (videoDuration <= 0) {
217
+ const metadata = await extractVideoMetadata(videoPath);
218
+ const sourceDuration = metadata.durationSeconds - video.mediaStart;
219
+ videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
220
+ video.end = video.start + videoDuration;
221
+ }
222
+
223
+ const result = await extractVideoFramesRange(
224
+ videoPath,
225
+ video.id,
226
+ video.mediaStart,
227
+ videoDuration,
228
+ options,
229
+ signal,
230
+ config,
231
+ );
232
+
233
+ return { result };
234
+ } catch (err) {
235
+ return {
236
+ error: {
237
+ videoId: video.id,
238
+ error: err instanceof Error ? err.message : String(err),
239
+ },
240
+ };
241
+ }
242
+ }),
243
+ );
244
+
245
+ // Collect results and errors
246
+ for (const item of results) {
247
+ if ("error" in item && item.error) {
248
+ errors.push(item.error);
249
+ } else if ("result" in item) {
250
+ extracted.push(item.result);
251
+ totalFramesExtracted += item.result.totalFrames;
252
+ }
253
+ }
254
+
255
+ return { success: errors.length === 0, extracted, errors, totalFramesExtracted, durationMs: Date.now() - startTime };
256
+ }
257
+
258
+ export function getFrameAtTime(extracted: ExtractedFrames, globalTime: number, videoStart: number): string | null {
259
+ const localTime = globalTime - videoStart;
260
+ if (localTime < 0) return null;
261
+ const frameIndex = Math.floor(localTime * extracted.fps);
262
+ if (frameIndex < 0 || frameIndex >= extracted.totalFrames) return null;
263
+ return extracted.framePaths.get(frameIndex) || null;
264
+ }
265
+
266
+ export class FrameLookupTable {
267
+ private videos: Map<
268
+ string,
269
+ {
270
+ extracted: ExtractedFrames;
271
+ start: number;
272
+ end: number;
273
+ mediaStart: number;
274
+ }
275
+ > = new Map();
276
+ private orderedVideos: Array<{
277
+ videoId: string;
278
+ extracted: ExtractedFrames;
279
+ start: number;
280
+ end: number;
281
+ mediaStart: number;
282
+ }> = [];
283
+ private activeVideoIds: Set<string> = new Set();
284
+ private startCursor = 0;
285
+ private lastTime: number | null = null;
286
+
287
+ addVideo(extracted: ExtractedFrames, start: number, end: number, mediaStart: number): void {
288
+ this.videos.set(extracted.videoId, { extracted, start, end, mediaStart });
289
+ this.orderedVideos = Array.from(this.videos.entries())
290
+ .map(([videoId, video]) => ({ videoId, ...video }))
291
+ .sort((a, b) => a.start - b.start);
292
+ this.resetActiveState();
293
+ }
294
+
295
+ getFrame(videoId: string, globalTime: number): string | null {
296
+ const video = this.videos.get(videoId);
297
+ if (!video) return null;
298
+ if (globalTime < video.start || globalTime >= video.end) return null;
299
+ return getFrameAtTime(video.extracted, globalTime, video.start);
300
+ }
301
+
302
+ private resetActiveState(): void {
303
+ this.activeVideoIds.clear();
304
+ this.startCursor = 0;
305
+ this.lastTime = null;
306
+ }
307
+
308
+ private refreshActiveSet(globalTime: number): void {
309
+ if (this.lastTime == null || globalTime < this.lastTime) {
310
+ this.activeVideoIds.clear();
311
+ this.startCursor = 0;
312
+ for (const entry of this.orderedVideos) {
313
+ if (entry.start <= globalTime && globalTime < entry.end) {
314
+ this.activeVideoIds.add(entry.videoId);
315
+ }
316
+ if (entry.start <= globalTime) {
317
+ this.startCursor += 1;
318
+ } else {
319
+ break;
320
+ }
321
+ }
322
+ this.lastTime = globalTime;
323
+ return;
324
+ }
325
+
326
+ while (this.startCursor < this.orderedVideos.length) {
327
+ const candidate = this.orderedVideos[this.startCursor];
328
+ if (!candidate) break;
329
+ if (candidate.start > globalTime) {
330
+ break;
331
+ }
332
+ if (globalTime < candidate.end) {
333
+ this.activeVideoIds.add(candidate.videoId);
334
+ }
335
+ this.startCursor += 1;
336
+ }
337
+
338
+ for (const videoId of Array.from(this.activeVideoIds)) {
339
+ const video = this.videos.get(videoId);
340
+ if (!video || globalTime < video.start || globalTime >= video.end) {
341
+ this.activeVideoIds.delete(videoId);
342
+ }
343
+ }
344
+ this.lastTime = globalTime;
345
+ }
346
+
347
+ getActiveFramePayloads(globalTime: number): Map<string, { framePath: string; frameIndex: number }> {
348
+ const frames = new Map<string, { framePath: string; frameIndex: number }>();
349
+ this.refreshActiveSet(globalTime);
350
+ for (const videoId of this.activeVideoIds) {
351
+ const video = this.videos.get(videoId);
352
+ if (!video) continue;
353
+ const localTime = globalTime - video.start;
354
+ const frameIndex = Math.floor(localTime * video.extracted.fps);
355
+ if (frameIndex < 0 || frameIndex >= video.extracted.totalFrames) continue;
356
+ const framePath = video.extracted.framePaths.get(frameIndex);
357
+ if (!framePath) continue;
358
+ frames.set(videoId, { framePath, frameIndex });
359
+ }
360
+ return frames;
361
+ }
362
+
363
+ getActiveFrames(globalTime: number): Map<string, string> {
364
+ const payloads = this.getActiveFramePayloads(globalTime);
365
+ const frames = new Map<string, string>();
366
+ for (const [videoId, payload] of payloads) {
367
+ frames.set(videoId, payload.framePath);
368
+ }
369
+ return frames;
370
+ }
371
+
372
+ cleanup(): void {
373
+ for (const video of this.videos.values()) {
374
+ if (existsSync(video.extracted.outputDir)) {
375
+ rmSync(video.extracted.outputDir, { recursive: true, force: true });
376
+ }
377
+ }
378
+ this.videos.clear();
379
+ this.orderedVideos = [];
380
+ this.resetActiveState();
381
+ }
382
+ }
383
+
384
+ export function createFrameLookupTable(videos: VideoElement[], extracted: ExtractedFrames[]): FrameLookupTable {
385
+ const table = new FrameLookupTable();
386
+ const extractedMap = new Map<string, ExtractedFrames>();
387
+ for (const ext of extracted) extractedMap.set(ext.videoId, ext);
388
+
389
+ for (const video of videos) {
390
+ const ext = extractedMap.get(video.id);
391
+ if (ext) table.addVideo(ext, video.start, video.end, video.mediaStart);
392
+ }
393
+
394
+ return table;
395
+ }