@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,392 @@
1
+ /**
2
+ * Audio Mixer Service
3
+ *
4
+ * Processes and mixes audio tracks using FFmpeg.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, rmSync } from "fs";
8
+ import { join, dirname } from "path";
9
+ import { parseHTML } from "linkedom";
10
+ import { extractAudioMetadata } from "../utils/ffprobe.js";
11
+ import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
12
+ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
13
+ import { runFfmpeg } from "../utils/runFfmpeg.js";
14
+ import type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
15
+
16
+ export type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
17
+
18
+ interface ExtractResult {
19
+ success: boolean;
20
+ outputPath: string;
21
+ durationMs: number;
22
+ error?: string;
23
+ }
24
+
25
+ export function parseAudioElements(html: string): AudioElement[] {
26
+ const elements: AudioElement[] = [];
27
+ const { document } = parseHTML(html);
28
+
29
+ // Parse <audio> elements
30
+ const audioEls = document.querySelectorAll("audio[id][src]");
31
+ for (const el of audioEls) {
32
+ const id = el.getAttribute("id");
33
+ const src = el.getAttribute("src");
34
+ if (!id || !src) continue;
35
+
36
+ const startAttr = el.getAttribute("data-start");
37
+ const endAttr = el.getAttribute("data-end");
38
+ const mediaStartAttr = el.getAttribute("data-media-start");
39
+ const layerAttr = el.getAttribute("data-layer");
40
+ const volumeAttr = el.getAttribute("data-volume");
41
+
42
+ elements.push({
43
+ id,
44
+ src,
45
+ start: startAttr ? parseFloat(startAttr) : 0,
46
+ end: endAttr ? parseFloat(endAttr) : 0,
47
+ mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
48
+ layer: layerAttr ? parseInt(layerAttr) : 0,
49
+ volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
50
+ type: "audio",
51
+ });
52
+ }
53
+
54
+ // Parse <video> elements with data-has-audio="true"
55
+ const videoEls = document.querySelectorAll('video[id][src][data-has-audio="true"]');
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 layerAttr = el.getAttribute("data-layer");
65
+ const volumeAttr = el.getAttribute("data-volume");
66
+
67
+ elements.push({
68
+ id: `${id}-audio`,
69
+ src,
70
+ start: startAttr ? parseFloat(startAttr) : 0,
71
+ end: endAttr ? parseFloat(endAttr) : 0,
72
+ mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
73
+ layer: layerAttr ? parseInt(layerAttr) : 0,
74
+ volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
75
+ type: "video",
76
+ });
77
+ }
78
+
79
+ return elements;
80
+ }
81
+
82
+ async function extractAudioFromVideo(
83
+ videoPath: string,
84
+ outputPath: string,
85
+ options?: { startTime?: number; duration?: number },
86
+ signal?: AbortSignal,
87
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
88
+ ): Promise<ExtractResult> {
89
+ const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
90
+ const outputDir = dirname(outputPath);
91
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
92
+
93
+ const args: string[] = ["-i", videoPath];
94
+ if (options?.startTime !== undefined) args.push("-ss", String(options.startTime));
95
+ if (options?.duration !== undefined) args.push("-t", String(options.duration));
96
+ args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", "-ac", "2", "-y", outputPath);
97
+
98
+ const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
99
+
100
+ if (signal?.aborted) {
101
+ return { success: false, outputPath, durationMs: result.durationMs, error: "Audio extract cancelled" };
102
+ }
103
+ if (!result.success) {
104
+ return {
105
+ success: false,
106
+ outputPath,
107
+ durationMs: result.durationMs,
108
+ error: result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
109
+ };
110
+ }
111
+ return { success: true, outputPath, durationMs: result.durationMs };
112
+ }
113
+
114
+ async function prepareAudioTrack(
115
+ srcPath: string,
116
+ outputPath: string,
117
+ mediaStart: number,
118
+ duration: number,
119
+ signal?: AbortSignal,
120
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
121
+ ): Promise<ExtractResult> {
122
+ const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
123
+ const outputDir = dirname(outputPath);
124
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
125
+
126
+ const args = [
127
+ "-ss",
128
+ String(mediaStart),
129
+ "-t",
130
+ String(duration),
131
+ "-i",
132
+ srcPath,
133
+ "-acodec",
134
+ "pcm_s16le",
135
+ "-ar",
136
+ "48000",
137
+ "-ac",
138
+ "2",
139
+ "-y",
140
+ outputPath,
141
+ ];
142
+
143
+ const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
144
+
145
+ if (signal?.aborted) {
146
+ return { success: false, outputPath, durationMs: result.durationMs, error: "Audio prepare cancelled" };
147
+ }
148
+ return {
149
+ success: result.success,
150
+ outputPath,
151
+ durationMs: result.durationMs,
152
+ error: !result.success
153
+ ? result.exitCode !== null
154
+ ? `FFmpeg exited with code ${result.exitCode}: ${result.stderr.slice(-200)}`
155
+ : result.stderr
156
+ : undefined,
157
+ };
158
+ }
159
+
160
+ async function generateSilence(
161
+ outputPath: string,
162
+ duration: number,
163
+ signal?: AbortSignal,
164
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
165
+ ): Promise<ExtractResult> {
166
+ const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
167
+ const outputDir = dirname(outputPath);
168
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
169
+
170
+ const args = [
171
+ "-f",
172
+ "lavfi",
173
+ "-i",
174
+ "anullsrc=r=48000:cl=stereo",
175
+ "-t",
176
+ String(duration),
177
+ "-acodec",
178
+ "pcm_s16le",
179
+ "-y",
180
+ outputPath,
181
+ ];
182
+
183
+ const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
184
+
185
+ if (signal?.aborted) {
186
+ return { success: false, outputPath, durationMs: result.durationMs, error: "Silence generation cancelled" };
187
+ }
188
+ return {
189
+ success: result.success,
190
+ outputPath,
191
+ durationMs: result.durationMs,
192
+ error: !result.success
193
+ ? result.exitCode !== null
194
+ ? `FFmpeg exited with code ${result.exitCode}`
195
+ : result.stderr
196
+ : undefined,
197
+ };
198
+ }
199
+
200
+ async function mixAudioTracks(
201
+ tracks: AudioTrack[],
202
+ outputPath: string,
203
+ totalDuration: number,
204
+ signal?: AbortSignal,
205
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>,
206
+ ): Promise<MixResult> {
207
+ const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
208
+ const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
209
+
210
+ if (tracks.length === 0) {
211
+ const result = await generateSilence(outputPath, totalDuration, signal, config);
212
+ return {
213
+ success: result.success,
214
+ outputPath,
215
+ durationMs: result.durationMs,
216
+ tracksProcessed: 0,
217
+ error: result.error,
218
+ };
219
+ }
220
+
221
+ const outputDir = dirname(outputPath);
222
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
223
+
224
+ const inputs: string[] = [];
225
+ const filterParts: string[] = [];
226
+
227
+ tracks.forEach((track, i) => {
228
+ inputs.push("-i", track.srcPath);
229
+ const delayMs = Math.round(track.start * 1000);
230
+ const trimDuration = track.end - track.start;
231
+ filterParts.push(
232
+ `[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`,
233
+ );
234
+ });
235
+
236
+ const mixInputs = tracks.map((_, i) => `[a${i}]`).join("");
237
+ const weights = tracks.map(() => "1").join(" ");
238
+ const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0:normalize=0:weights='${weights}'[mixed]`;
239
+ const postMixGainFilter = `[mixed]volume=${masterOutputGain}[out]`;
240
+ const fullFilter = [...filterParts, mixFilter, postMixGainFilter].join(";");
241
+
242
+ const args = [
243
+ ...inputs,
244
+ "-filter_complex",
245
+ fullFilter,
246
+ "-map",
247
+ "[out]",
248
+ "-acodec",
249
+ "aac",
250
+ "-b:a",
251
+ "192k",
252
+ "-t",
253
+ String(totalDuration),
254
+ "-y",
255
+ outputPath,
256
+ ];
257
+
258
+ const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
259
+
260
+ if (signal?.aborted) {
261
+ return {
262
+ success: false,
263
+ outputPath,
264
+ durationMs: result.durationMs,
265
+ tracksProcessed: 0,
266
+ error: "Audio mix cancelled",
267
+ };
268
+ }
269
+ if (!result.success) {
270
+ return {
271
+ success: false,
272
+ outputPath,
273
+ durationMs: result.durationMs,
274
+ tracksProcessed: 0,
275
+ error: result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
276
+ };
277
+ }
278
+ return { success: true, outputPath, durationMs: result.durationMs, tracksProcessed: tracks.length };
279
+ }
280
+
281
+ export async function processCompositionAudio(
282
+ elements: AudioElement[],
283
+ baseDir: string,
284
+ workDir: string,
285
+ outputPath: string,
286
+ totalDuration: number,
287
+ signal?: AbortSignal,
288
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>,
289
+ ): Promise<MixResult> {
290
+ const startMs = Date.now();
291
+ const tracks: AudioTrack[] = [];
292
+ const errors: string[] = [];
293
+
294
+ if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true });
295
+
296
+ await Promise.all(
297
+ elements.map(async (element) => {
298
+ if (signal?.aborted) {
299
+ errors.push(`Cancelled: ${element.id}`);
300
+ return;
301
+ }
302
+ try {
303
+ let srcPath = element.src;
304
+ if (!srcPath.startsWith("/") && !isHttpUrl(srcPath)) {
305
+ srcPath = join(baseDir, srcPath);
306
+ }
307
+
308
+ if (isHttpUrl(srcPath)) {
309
+ try {
310
+ srcPath = await downloadToTemp(srcPath, workDir);
311
+ } catch (err: unknown) {
312
+ errors.push(`Download failed: ${element.id} — ${err instanceof Error ? err.message : String(err)}`);
313
+ return;
314
+ }
315
+ }
316
+
317
+ if (!existsSync(srcPath)) {
318
+ errors.push(`Source not found: ${element.id}`);
319
+ return;
320
+ }
321
+
322
+ // Fallback: if no duration was specified, probe the actual file
323
+ if (element.end - element.start <= 0) {
324
+ const metadata = await extractAudioMetadata(srcPath);
325
+ const effectiveDuration = metadata.durationSeconds - element.mediaStart;
326
+ element.end = element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
327
+ }
328
+
329
+ let audioSrcPath = srcPath;
330
+ if (element.type === "video") {
331
+ const extractedPath = join(workDir, `${element.id}-extracted.wav`);
332
+ const extractResult = await extractAudioFromVideo(
333
+ srcPath,
334
+ extractedPath,
335
+ {
336
+ startTime: element.mediaStart,
337
+ duration: element.end - element.start,
338
+ },
339
+ signal,
340
+ config,
341
+ );
342
+ if (!extractResult.success) {
343
+ errors.push(`Extract failed: ${element.id}`);
344
+ return;
345
+ }
346
+ audioSrcPath = extractedPath;
347
+ } else {
348
+ const trimmedPath = join(workDir, `${element.id}-trimmed.wav`);
349
+ const prepResult = await prepareAudioTrack(
350
+ srcPath,
351
+ trimmedPath,
352
+ element.mediaStart,
353
+ element.end - element.start,
354
+ signal,
355
+ config,
356
+ );
357
+ if (!prepResult.success) {
358
+ errors.push(`Prepare failed: ${element.id}`);
359
+ return;
360
+ }
361
+ audioSrcPath = trimmedPath;
362
+ }
363
+
364
+ tracks.push({
365
+ id: element.id,
366
+ srcPath: audioSrcPath,
367
+ start: element.start,
368
+ end: element.end,
369
+ mediaStart: element.mediaStart,
370
+ duration: element.end - element.start,
371
+ volume: element.volume || 1.0,
372
+ });
373
+ } catch (err: unknown) {
374
+ errors.push(`Error: ${element.id} — ${err instanceof Error ? err.message : String(err)}`);
375
+ }
376
+ }),
377
+ );
378
+
379
+ const mixResult = await mixAudioTracks(tracks, outputPath, totalDuration, signal, config);
380
+
381
+ try {
382
+ rmSync(workDir, { recursive: true, force: true });
383
+ } catch {
384
+ /* ignore */
385
+ }
386
+
387
+ return {
388
+ ...mixResult,
389
+ durationMs: Date.now() - startMs,
390
+ error: errors.length > 0 ? `Warnings: ${errors.join(", ")}` : mixResult.error,
391
+ };
392
+ }
@@ -0,0 +1,28 @@
1
+ export interface AudioElement {
2
+ id: string;
3
+ src: string;
4
+ start: number;
5
+ end: number;
6
+ mediaStart: number;
7
+ layer: number;
8
+ volume?: number;
9
+ type: "audio" | "video";
10
+ }
11
+
12
+ export interface AudioTrack {
13
+ id: string;
14
+ srcPath: string;
15
+ start: number;
16
+ end: number;
17
+ mediaStart: number;
18
+ duration: number;
19
+ volume: number;
20
+ }
21
+
22
+ export interface MixResult {
23
+ success: boolean;
24
+ outputPath: string;
25
+ durationMs: number;
26
+ tracksProcessed: number;
27
+ error?: string;
28
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Browser Manager
3
+ *
4
+ * Manages Puppeteer browser lifecycle: Chrome executable resolution,
5
+ * launch args, pooled browser acquisition/release.
6
+ */
7
+
8
+ import type { Browser, PuppeteerNode } from "puppeteer-core";
9
+ import { existsSync, readdirSync } from "fs";
10
+ import { join } from "path";
11
+ import { homedir } from "os";
12
+ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
13
+
14
+ let _puppeteer: PuppeteerNode | undefined;
15
+
16
+ async function getPuppeteer(): Promise<PuppeteerNode> {
17
+ if (_puppeteer) return _puppeteer;
18
+ try {
19
+ const mod = await import("puppeteer" as string);
20
+ _puppeteer = mod.default;
21
+ } catch {
22
+ const mod = await import("puppeteer-core");
23
+ _puppeteer = mod.default;
24
+ }
25
+ if (!_puppeteer) throw new Error("Neither puppeteer nor puppeteer-core found");
26
+ return _puppeteer;
27
+ }
28
+
29
+ // "beginframe" = atomic compositor control via HeadlessExperimental.beginFrame (Linux only)
30
+ // "screenshot" = renderSeek + Page.captureScreenshot (all platforms)
31
+ export type CaptureMode = "beginframe" | "screenshot";
32
+
33
+ export interface AcquiredBrowser {
34
+ browser: Browser;
35
+ captureMode: CaptureMode;
36
+ }
37
+
38
+ /**
39
+ * Resolve chrome-headless-shell binary for deterministic BeginFrame rendering.
40
+ * Checks config.chromePath, then PRODUCER_HEADLESS_SHELL_PATH env var,
41
+ * then scans Puppeteer's managed cache at ~/.cache/puppeteer/chrome-headless-shell/.
42
+ */
43
+ export function resolveHeadlessShellPath(config?: Partial<Pick<EngineConfig, "chromePath">>): string | undefined {
44
+ if (config?.chromePath) {
45
+ return config.chromePath;
46
+ }
47
+ if (process.env.PRODUCER_HEADLESS_SHELL_PATH) {
48
+ return process.env.PRODUCER_HEADLESS_SHELL_PATH;
49
+ }
50
+ const baseDir = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
51
+ if (!existsSync(baseDir)) return undefined;
52
+ try {
53
+ const versions = readdirSync(baseDir).sort().reverse(); // newest first
54
+ for (const version of versions) {
55
+ const candidates = [
56
+ join(baseDir, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
57
+ join(baseDir, version, "chrome-headless-shell-mac-arm64", "chrome-headless-shell"),
58
+ join(baseDir, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
59
+ join(baseDir, version, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
60
+ ];
61
+ for (const binary of candidates) {
62
+ if (existsSync(binary)) return binary;
63
+ }
64
+ }
65
+ } catch {
66
+ // ignore
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ let pooledBrowser: Browser | null = null;
72
+ let pooledBrowserRefCount = 0;
73
+ let pooledCaptureMode: CaptureMode = "screenshot";
74
+
75
+ // Preserve the producer-era export so re-export shims keep the same public API.
76
+ export const ENABLE_BROWSER_POOL = DEFAULT_CONFIG.enableBrowserPool;
77
+
78
+ export async function acquireBrowser(
79
+ chromeArgs: string[],
80
+ config?: Partial<
81
+ Pick<EngineConfig, "browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot">
82
+ >,
83
+ ): Promise<AcquiredBrowser> {
84
+ const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
85
+
86
+ if (enablePool && pooledBrowser) {
87
+ pooledBrowserRefCount += 1;
88
+ return { browser: pooledBrowser, captureMode: pooledCaptureMode };
89
+ }
90
+
91
+ // Config chromePath overrides env var / auto-detection.
92
+ const headlessShell = resolveHeadlessShellPath(config);
93
+
94
+ // BeginFrame requires chrome-headless-shell AND Linux (crashes on macOS/Windows).
95
+ const isLinux = process.platform === "linux";
96
+ const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
97
+ let captureMode: CaptureMode;
98
+ let executablePath: string | undefined;
99
+
100
+ if (headlessShell && isLinux && !forceScreenshot) {
101
+ captureMode = "beginframe";
102
+ executablePath = headlessShell;
103
+ } else {
104
+ // Screenshot mode with renderSeek: works on all platforms.
105
+ captureMode = "screenshot";
106
+ if (headlessShell && !isLinux) {
107
+ executablePath = headlessShell;
108
+ } else {
109
+ executablePath = undefined; // Puppeteer uses its bundled Chromium
110
+ }
111
+ }
112
+
113
+ const ppt = await getPuppeteer();
114
+ const browser = await ppt.launch({
115
+ headless: true,
116
+ args: chromeArgs,
117
+ defaultViewport: null,
118
+ executablePath,
119
+ timeout: config?.browserTimeout ?? DEFAULT_CONFIG.browserTimeout,
120
+ protocolTimeout: config?.protocolTimeout ?? DEFAULT_CONFIG.protocolTimeout,
121
+ });
122
+ if (enablePool) {
123
+ pooledBrowser = browser;
124
+ pooledBrowserRefCount = 1;
125
+ pooledCaptureMode = captureMode;
126
+ }
127
+ return { browser, captureMode };
128
+ }
129
+
130
+ export async function releaseBrowser(
131
+ browser: Browser,
132
+ config?: Partial<Pick<EngineConfig, "enableBrowserPool">>,
133
+ ): Promise<void> {
134
+ const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
135
+ if (!enablePool) {
136
+ await browser.close().catch(() => {});
137
+ return;
138
+ }
139
+ if (pooledBrowser && pooledBrowser === browser) {
140
+ pooledBrowserRefCount = Math.max(0, pooledBrowserRefCount - 1);
141
+ if (pooledBrowserRefCount === 0) {
142
+ await browser.close().catch(() => {});
143
+ pooledBrowser = null;
144
+ }
145
+ return;
146
+ }
147
+ await browser.close().catch(() => {});
148
+ }
149
+
150
+ export interface BuildChromeArgsOptions {
151
+ width: number;
152
+ height: number;
153
+ captureMode?: CaptureMode;
154
+ }
155
+
156
+ export function buildChromeArgs(
157
+ options: BuildChromeArgsOptions,
158
+ config?: Partial<Pick<EngineConfig, "disableGpu" | "chromePath">>,
159
+ ): string[] {
160
+ // Chrome flags tuned for headless rendering performance.
161
+ // Based on Remotion's open-browser.ts flags with additions for our use case.
162
+ const chromeArgs = [
163
+ "--no-sandbox",
164
+ "--disable-setuid-sandbox",
165
+ "--disable-dev-shm-usage",
166
+ "--enable-webgl",
167
+ "--ignore-gpu-blocklist",
168
+ "--use-gl=angle",
169
+ "--use-angle=swiftshader",
170
+ "--font-render-hinting=none",
171
+ "--force-color-profile=srgb",
172
+ `--window-size=${options.width},${options.height}`,
173
+ // Remotion perf flags — prevent Chrome from throttling background tabs/timers
174
+ "--disable-background-timer-throttling",
175
+ "--disable-backgrounding-occluded-windows",
176
+ "--disable-renderer-backgrounding",
177
+ "--disable-background-media-suspend",
178
+ // Reduce overhead from unused Chrome features
179
+ "--disable-breakpad",
180
+ "--disable-component-extensions-with-background-pages",
181
+ "--disable-default-apps",
182
+ "--disable-extensions",
183
+ "--disable-hang-monitor",
184
+ "--disable-ipc-flooding-protection",
185
+ "--disable-popup-blocking",
186
+ "--disable-sync",
187
+ "--disable-component-update",
188
+ "--disable-domain-reliability",
189
+ "--disable-print-preview",
190
+ "--no-pings",
191
+ "--no-zygote",
192
+ // Memory
193
+ "--force-gpu-mem-available-mb=4096",
194
+ "--disk-cache-size=268435456",
195
+ // Disable features that add overhead
196
+ "--disable-features=AudioServiceOutOfProcess,IsolateOrigins,site-per-process,Translate,BackForwardCache,IntensiveWakeUpThrottling",
197
+ ];
198
+
199
+ // BeginFrame flags — only when using chrome-headless-shell on Linux
200
+ if (options.captureMode !== "screenshot") {
201
+ chromeArgs.push(
202
+ "--deterministic-mode",
203
+ "--enable-begin-frame-control",
204
+ "--disable-new-content-rendering-timeout",
205
+ "--run-all-compositor-stages-before-draw",
206
+ "--disable-threaded-animation",
207
+ "--disable-threaded-scrolling",
208
+ "--disable-checker-imaging",
209
+ "--disable-image-animation-resync",
210
+ "--enable-surface-synchronization",
211
+ );
212
+ }
213
+
214
+ const gpuDisabled = config?.disableGpu ?? DEFAULT_CONFIG.disableGpu;
215
+ if (gpuDisabled) {
216
+ chromeArgs.push("--disable-gpu");
217
+ }
218
+ return chromeArgs;
219
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ENCODER_PRESETS } from "./chunkEncoder.js";
3
+
4
+ describe("ENCODER_PRESETS", () => {
5
+ it("has draft, standard, and high presets", () => {
6
+ expect(ENCODER_PRESETS).toHaveProperty("draft");
7
+ expect(ENCODER_PRESETS).toHaveProperty("standard");
8
+ expect(ENCODER_PRESETS).toHaveProperty("high");
9
+ });
10
+
11
+ it("draft uses ultrafast preset with high CRF", () => {
12
+ expect(ENCODER_PRESETS.draft.preset).toBe("ultrafast");
13
+ expect(ENCODER_PRESETS.draft.quality).toBeGreaterThan(ENCODER_PRESETS.standard.quality);
14
+ expect(ENCODER_PRESETS.draft.codec).toBe("h264");
15
+ });
16
+
17
+ it("high uses slow preset with low CRF for better quality", () => {
18
+ expect(ENCODER_PRESETS.high.preset).toBe("slow");
19
+ expect(ENCODER_PRESETS.high.quality).toBeLessThan(ENCODER_PRESETS.standard.quality);
20
+ expect(ENCODER_PRESETS.high.codec).toBe("h264");
21
+ });
22
+
23
+ it("standard sits between draft and high in quality", () => {
24
+ expect(ENCODER_PRESETS.standard.quality).toBeGreaterThan(ENCODER_PRESETS.high.quality);
25
+ expect(ENCODER_PRESETS.standard.quality).toBeLessThan(ENCODER_PRESETS.draft.quality);
26
+ });
27
+ });