@agent-foundry/replay-server 1.0.2 → 1.0.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.
@@ -1,665 +0,0 @@
1
- /**
2
- * Puppeteer-based Video Renderer
3
- *
4
- * This renderer:
5
- * 1. Launches a headless browser
6
- * 2. Opens the game at a special replay URL
7
- * 3. Injects the replay manifest
8
- * 4. Captures screenshots as JPEG buffers and streams directly to FFmpeg via stdin
9
- * 5. Encodes video using libx264 (CPU) by default, or h264_nvenc if explicitly enabled
10
- */
11
-
12
- import puppeteer, { Browser, Page } from 'puppeteer';
13
- import { spawn, ChildProcess } from 'child_process';
14
- import * as path from 'path';
15
-
16
- /**
17
- * Generic replay manifest interface
18
- * Supports any game type - games interpret their own schemas
19
- */
20
- export interface GenericReplayManifest {
21
- schema: string;
22
- bundleId?: string;
23
- gameId: string;
24
- timeline?: unknown[]; // Optional - games may not have timelines (LifeRestart uses this)
25
- summary?: {
26
- durationSeconds?: number; // JumpArena provides duration directly
27
- [key: string]: unknown;
28
- };
29
- [key: string]: unknown;
30
- }
31
-
32
- /**
33
- * Replay mode determines how frames are captured:
34
- * - 'age-based': Discrete ages with replay-next-age events (LifeRestart)
35
- * - 'continuous': Real-time simulation without events (JumpArena)
36
- */
37
- type ReplayMode = 'age-based' | 'continuous';
38
-
39
- /**
40
- * Replay timing configuration calculated from manifest
41
- */
42
- interface ReplayTiming {
43
- mode: ReplayMode;
44
- durationSeconds: number;
45
- totalFrames: number;
46
- }
47
-
48
- export interface RenderConfig {
49
- /** URL of the game (can be local file:// or http://) */
50
- gameUrl: string;
51
-
52
- /** Output video path */
53
- outputPath: string;
54
-
55
- /** Video dimensions */
56
- width?: number;
57
- height?: number;
58
-
59
- /** Frames per second */
60
- fps?: number;
61
-
62
- /** Seconds to display each age */
63
- secondsPerAge?: number;
64
-
65
- /** Enable hardware acceleration (NVENC). Default: false (uses CPU libx264) */
66
- useHardwareAcceleration?: boolean;
67
-
68
- /** Temporary directory for screenshots (deprecated, no longer used) */
69
- tempDir?: string;
70
-
71
- /** Whether to keep temp files after rendering (deprecated, no longer used) */
72
- keepTempFiles?: boolean;
73
-
74
- /** Callback for progress updates */
75
- onProgress?: (progress: RenderProgress) => void;
76
- }
77
-
78
- export interface RenderProgress {
79
- stage: 'init' | 'capture' | 'encode' | 'complete' | 'error';
80
- current: number;
81
- total: number;
82
- message: string;
83
- }
84
-
85
- export interface RenderResult {
86
- success: boolean;
87
- outputPath?: string;
88
- duration?: number;
89
- error?: string;
90
- }
91
-
92
- const DEFAULT_CONFIG = {
93
- width: 720,
94
- height: 1280,
95
- fps: 16,
96
- secondsPerAge: 1.2,
97
- keepTempFiles: false,
98
- useHardwareAcceleration: false, // Default to CPU encoding
99
- };
100
-
101
- export class PuppeteerRenderer {
102
- private browser: Browser | null = null;
103
- private page: Page | null = null;
104
- private pageErrors: string[] = [];
105
- private failedRequests: Array<{ url: string; errorText?: string }> = [];
106
-
107
- /**
108
- * Calculate replay timing based on manifest fields
109
- *
110
- * Detection priority (field-based, not schema-name based):
111
- * 1. If manifest.summary.durationSeconds exists → continuous mode
112
- * 2. If manifest.timeline[] exists → age-based mode
113
- * 3. Fallback to 10s continuous mode
114
- *
115
- * Continuous mode: Real-time simulation, frames captured at regular intervals
116
- * Age-based mode: Discrete ages with replay-next-age events
117
- */
118
- private calculateReplayTiming(
119
- manifest: GenericReplayManifest,
120
- config: { fps: number; secondsPerAge: number }
121
- ): ReplayTiming {
122
- // Priority 1: Check for summary.durationSeconds (continuous mode)
123
- const durationSeconds = manifest.summary?.durationSeconds;
124
- if (typeof durationSeconds === 'number' && durationSeconds > 0) {
125
- const totalFrames = Math.ceil(durationSeconds * config.fps);
126
- console.log(`📼 Continuous mode (durationSeconds): ${durationSeconds.toFixed(2)}s, ${totalFrames} frames`);
127
- return {
128
- mode: 'continuous',
129
- durationSeconds,
130
- totalFrames,
131
- };
132
- }
133
-
134
- // Priority 2: Check for timeline[] (age-based mode)
135
- if (Array.isArray(manifest.timeline) && manifest.timeline.length > 0) {
136
- const timelineLength = manifest.timeline.length;
137
- const duration = timelineLength * config.secondsPerAge;
138
- const totalFrames = Math.ceil(duration * config.fps);
139
- console.log(`📼 Age-based mode (timeline): ${timelineLength} ages, ${duration.toFixed(2)}s, ${totalFrames} frames`);
140
- return {
141
- mode: 'age-based',
142
- durationSeconds: duration,
143
- totalFrames,
144
- };
145
- }
146
-
147
- // Fallback: No duration info, use default continuous mode
148
- console.warn('📼 No duration info in manifest, using default 10s continuous mode');
149
- return {
150
- mode: 'continuous',
151
- durationSeconds: 10,
152
- totalFrames: Math.ceil(10 * config.fps),
153
- };
154
- }
155
-
156
- /**
157
- * Render a replay manifest to video
158
- */
159
- async render(manifest: GenericReplayManifest, config: RenderConfig): Promise<RenderResult> {
160
- const startTime = Date.now();
161
- const fullConfig: RenderConfig & typeof DEFAULT_CONFIG = {
162
- ...DEFAULT_CONFIG,
163
- gameUrl: config.gameUrl,
164
- outputPath: config.outputPath,
165
- width: config.width ?? DEFAULT_CONFIG.width,
166
- height: config.height ?? DEFAULT_CONFIG.height,
167
- fps: config.fps ?? DEFAULT_CONFIG.fps,
168
- secondsPerAge: config.secondsPerAge ?? DEFAULT_CONFIG.secondsPerAge,
169
- useHardwareAcceleration: config.useHardwareAcceleration ?? DEFAULT_CONFIG.useHardwareAcceleration,
170
- onProgress: config.onProgress,
171
- };
172
-
173
- try {
174
- // Initialize browser
175
- this.reportProgress(config.onProgress, 'init', 0, 1, 'Launching browser...');
176
- await this.initBrowser(fullConfig);
177
-
178
- // Navigate to game
179
- this.reportProgress(config.onProgress, 'init', 0, 1, 'Loading game...');
180
- await this.loadGame(fullConfig.gameUrl, manifest);
181
-
182
- // Calculate timing based on manifest schema
183
- const timing = this.calculateReplayTiming(manifest, {
184
- fps: fullConfig.fps,
185
- secondsPerAge: fullConfig.secondsPerAge,
186
- });
187
-
188
- // Capture and encode frames in streaming mode
189
- this.reportProgress(config.onProgress, 'capture', 0, timing.totalFrames, 'Starting capture & encode...');
190
- await this.captureAndEncodeFrames(manifest, fullConfig, timing, config.onProgress);
191
-
192
- const duration = (Date.now() - startTime) / 1000;
193
- this.reportProgress(config.onProgress, 'complete', 1, 1, `Completed in ${duration.toFixed(1)}s`);
194
-
195
- return {
196
- success: true,
197
- outputPath: fullConfig.outputPath,
198
- duration,
199
- };
200
- } catch (error) {
201
- const errorMessage = error instanceof Error ? error.message : String(error);
202
- this.reportProgress(config.onProgress, 'error', 0, 1, errorMessage);
203
-
204
- return {
205
- success: false,
206
- error: errorMessage,
207
- };
208
- } finally {
209
- await this.closeBrowser();
210
- }
211
- }
212
-
213
- private async initBrowser(config: RenderConfig & typeof DEFAULT_CONFIG): Promise<void> {
214
- this.browser = await puppeteer.launch({
215
- executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium',
216
- headless: true,
217
- timeout: 120_000, // 2 minutes for cold starts
218
- dumpio: true, // CRITICAL: logs chromium stderr/stdout
219
- args: [
220
- '--no-sandbox',
221
- '--disable-setuid-sandbox',
222
- '--disable-dev-shm-usage', // /dev/shm is small in containers
223
- '--no-zygote', // no zygote process in containers
224
- '--single-process', // safer in containerized environments
225
- '--disable-gpu',
226
- '--disable-software-rasterizer',
227
- '--disable-background-networking',
228
- '--disable-default-apps',
229
- '--disable-extensions',
230
- '--disable-sync',
231
- '--disable-translate',
232
- '--metrics-recording-only',
233
- '--mute-audio',
234
- '--no-first-run',
235
- '--safebrowsing-disable-auto-update',
236
- `--window-size=${config.width},${config.height}`,
237
- ],
238
- });
239
-
240
- this.page = await this.browser.newPage();
241
- await this.page.setViewport({
242
- width: config.width,
243
- height: config.height,
244
- deviceScaleFactor: 1,
245
- });
246
-
247
- // Set up error tracking
248
- this.pageErrors = [];
249
- this.failedRequests = [];
250
-
251
- this.page.on('pageerror', (error) => {
252
- this.pageErrors.push(error.message);
253
- console.error('📼 Page error:', error.message);
254
- });
255
-
256
- this.page.on('requestfailed', (request) => {
257
- const failure = request.failure();
258
- this.failedRequests.push({
259
- url: request.url(),
260
- errorText: failure?.errorText,
261
- });
262
- console.error('📼 Request failed:', request.url(), failure?.errorText);
263
- });
264
- }
265
-
266
- private async loadGame(gameUrl: string, manifest: GenericReplayManifest): Promise<void> {
267
- if (!this.page) throw new Error('Browser not initialized');
268
-
269
- // Navigate to game with replay mode query parameter
270
- const url = new URL(gameUrl);
271
- url.searchParams.set('mode', 'replay');
272
-
273
- console.log(`📼 Navigating to ${url.toString()}`);
274
-
275
- await this.page.goto(url.toString(), {
276
- waitUntil: 'networkidle0',
277
- timeout: 30000,
278
- });
279
-
280
- console.log('📼 Page loaded, waiting for React to initialize...');
281
-
282
- // Wait for React to initialize
283
- // Try to detect common app containers, but don't fail if not found
284
- try {
285
- await this.page.waitForFunction(() => {
286
- // Check for common app container patterns
287
- const ionApp = document.querySelector('ion-app');
288
- const rootDiv = document.querySelector('#root');
289
- const appDiv = document.querySelector('[id*="app"], [class*="app"]');
290
-
291
- // If using Ionic, wait for loading to complete
292
- if (ionApp) {
293
- const loading = document.querySelector('ion-spinner');
294
- return loading === null;
295
- }
296
-
297
- // Otherwise, just check if root is mounted
298
- return rootDiv !== null || appDiv !== null;
299
- }, { timeout: 10000 });
300
- console.log('📼 App container detected');
301
- } catch (error) {
302
- // Non-fatal - continue anyway
303
- console.log('📼 App container detection timed out, continuing anyway...');
304
- }
305
-
306
- // Log any errors that occurred (but didn't prevent initialization)
307
- if (this.pageErrors.length > 0) {
308
- console.warn('📼 Page errors detected (non-fatal):', this.pageErrors);
309
- }
310
- if (this.failedRequests.length > 0) {
311
- console.warn('📼 Failed requests (non-fatal):', this.failedRequests);
312
- }
313
-
314
- console.log('📼 React initialized, injecting manifest...');
315
-
316
- // Inject the replay manifest with retry logic
317
- const maxRetries = 3;
318
- let retryCount = 0;
319
- let success = false;
320
-
321
- while (retryCount < maxRetries && !success) {
322
- // Inject the replay manifest
323
- await this.page.evaluate((manifestJson: string) => {
324
- // Store manifest in window for the game to read
325
- (window as unknown as { __REPLAY_MANIFEST__: unknown }).__REPLAY_MANIFEST__ = JSON.parse(manifestJson);
326
-
327
- console.log('📼 Manifest injected, dispatching event...');
328
-
329
- // Dispatch event to notify the game
330
- window.dispatchEvent(new CustomEvent('replay-manifest-loaded', {
331
- detail: JSON.parse(manifestJson)
332
- }));
333
- }, JSON.stringify(manifest));
334
-
335
- // Wait for the replay ready indicator
336
- try {
337
- await this.page.waitForSelector('[data-replay-ready="true"]', {
338
- timeout: 5000,
339
- });
340
- success = true;
341
- console.log('📼 Replay mode ready!');
342
- } catch {
343
- retryCount++;
344
- console.log(`📼 Retry ${retryCount}/${maxRetries}: Waiting for replay ready...`);
345
-
346
- if (retryCount < maxRetries) {
347
- // Small delay before retry
348
- await new Promise(resolve => setTimeout(resolve, 500));
349
- }
350
- }
351
- }
352
-
353
- if (!success) {
354
- console.warn('📼 Warning: Could not confirm replay ready state, proceeding anyway...');
355
- // Give the app a bit more time to process
356
- await new Promise(resolve => setTimeout(resolve, 2000));
357
- }
358
-
359
- // Additional wait for UI to stabilize
360
- await new Promise(resolve => setTimeout(resolve, 500));
361
- }
362
-
363
- /**
364
- * Capture frames and encode video in streaming mode using FFmpeg stdin
365
- * Frames are captured as JPEG buffers and streamed directly to FFmpeg
366
- */
367
- private async captureAndEncodeFrames(
368
- manifest: GenericReplayManifest,
369
- config: RenderConfig & typeof DEFAULT_CONFIG,
370
- timing: ReplayTiming,
371
- onProgress?: (progress: RenderProgress) => void
372
- ): Promise<void> {
373
- // Use encoder based on configuration
374
- const encoder = config.useHardwareAcceleration ? 'h264_nvenc' : 'libx264';
375
- console.log(`📼 Using encoder: ${encoder} (Hardware acceleration: ${config.useHardwareAcceleration ? 'enabled' : 'disabled'})`);
376
-
377
- await this.captureAndEncodeFramesWithEncoder(manifest, config, timing, encoder, onProgress);
378
- }
379
-
380
- /**
381
- * Internal method to capture and encode with a specific encoder
382
- */
383
- private async captureAndEncodeFramesWithEncoder(
384
- manifest: GenericReplayManifest,
385
- config: RenderConfig & typeof DEFAULT_CONFIG,
386
- timing: ReplayTiming,
387
- encoder: 'h264_nvenc' | 'libx264',
388
- onProgress?: (progress: RenderProgress) => void
389
- ): Promise<void> {
390
- if (!this.page) throw new Error('Browser not initialized');
391
-
392
- const { mode, totalFrames, durationSeconds } = timing;
393
-
394
- // For age-based mode, calculate ages and frames per age
395
- const timelineLength = Array.isArray(manifest.timeline) ? manifest.timeline.length : 1;
396
- const totalAges = timelineLength;
397
- const framesPerAge = Math.ceil(config.secondsPerAge * config.fps);
398
-
399
- console.log(`📼 Replay mode: ${mode}, duration: ${durationSeconds.toFixed(2)}s, frames: ${totalFrames}`);
400
- console.log(`📼 Using encoder: ${encoder}`);
401
-
402
- // Launch FFmpeg process with stdin input
403
- const ffmpeg = this.launchFFmpeg(encoder, config);
404
-
405
- let frameIndex = 0;
406
- let ffmpegStderr = '';
407
-
408
- // Create promise for waiting FFmpeg completion
409
- let ffmpegResolve: (() => void) | null = null;
410
- let ffmpegReject: ((err: Error) => void) | null = null;
411
- let ffmpegClosed = false;
412
- let encodingTimeout: NodeJS.Timeout | null = null;
413
-
414
- // Create promise that will be resolved when FFmpeg finishes
415
- const encodingPromise = new Promise<void>((resolve, reject) => {
416
- ffmpegResolve = resolve;
417
- ffmpegReject = reject;
418
-
419
- // Set timeout to prevent hanging (5 minutes should be enough for encoding)
420
- encodingTimeout = setTimeout(() => {
421
- if (!ffmpegClosed) {
422
- ffmpegClosed = true;
423
- const timeoutError = new Error(`FFmpeg encoding timeout after 5 minutes. Process may be stuck.`);
424
- reject(timeoutError);
425
- // Force kill if still running
426
- if (ffmpeg && !ffmpeg.killed) {
427
- ffmpeg.kill('SIGKILL');
428
- }
429
- }
430
- }, 5 * 60 * 1000); // 5 minutes timeout
431
- });
432
-
433
- // Handle FFmpeg stderr output (for logging and debugging)
434
- ffmpeg.stderr?.on('data', (data: Buffer) => {
435
- const output = data.toString();
436
- ffmpegStderr += output;
437
- });
438
-
439
- // Handle FFmpeg process errors
440
- ffmpeg.on('error', (err: Error) => {
441
- if (ffmpegReject && !ffmpegClosed) {
442
- ffmpegClosed = true;
443
- if (encodingTimeout) clearTimeout(encodingTimeout);
444
- ffmpegReject(new Error(`Failed to start FFmpeg: ${err.message}`));
445
- }
446
- });
447
-
448
- // Set up close event handler BEFORE starting capture
449
- ffmpeg.on('close', (code: number) => {
450
- if (ffmpegClosed) return; // Prevent multiple calls
451
- ffmpegClosed = true;
452
-
453
- if (encodingTimeout) {
454
- clearTimeout(encodingTimeout);
455
- }
456
-
457
- if (code === 0) {
458
- if (ffmpegResolve) {
459
- ffmpegResolve();
460
- }
461
- } else {
462
- const error = new Error(`FFmpeg exited with code ${code}. Stderr: ${ffmpegStderr}`);
463
- if (ffmpegReject) {
464
- ffmpegReject(error);
465
- }
466
- }
467
- });
468
-
469
- // Wait for FFmpeg stdin to be ready
470
- if (!ffmpeg.stdin) {
471
- throw new Error('FFmpeg stdin not available');
472
- }
473
-
474
- try {
475
- // Frame interval in ms (for continuous mode timing)
476
- const frameIntervalMs = 1000 / config.fps;
477
-
478
- if (mode === 'continuous') {
479
- // CONTINUOUS MODE: Capture frames at regular intervals
480
- // Game runs in real-time, we just capture screenshots
481
- console.log(`📼 Continuous mode: capturing ${totalFrames} frames over ${durationSeconds.toFixed(2)}s`);
482
-
483
- for (let f = 0; f < totalFrames; f++) {
484
- // Check if FFmpeg process has closed unexpectedly
485
- if (ffmpegClosed) {
486
- throw new Error('FFmpeg process closed unexpectedly');
487
- }
488
-
489
- // Capture screenshot as JPEG buffer
490
- const screenshotBuffer = await this.page.screenshot({
491
- type: 'jpeg',
492
- quality: 85,
493
- encoding: 'binary',
494
- }) as Buffer;
495
-
496
- // Write frame to FFmpeg stdin
497
- if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
498
- const writeSuccess = ffmpeg.stdin.write(screenshotBuffer);
499
- if (!writeSuccess) {
500
- // Wait for drain event if buffer is full
501
- await new Promise<void>((resolve) => {
502
- if (ffmpeg.stdin) {
503
- ffmpeg.stdin.once('drain', resolve);
504
- } else {
505
- resolve();
506
- }
507
- });
508
- }
509
- }
510
-
511
- frameIndex++;
512
-
513
- // Update progress every second
514
- if (frameIndex % config.fps === 0) {
515
- this.reportProgress(
516
- onProgress,
517
- 'capture',
518
- frameIndex,
519
- totalFrames,
520
- `Capturing & encoding frame ${frameIndex}/${totalFrames} (${encoder})`
521
- );
522
- }
523
-
524
- // Wait for next frame interval (allow game to advance simulation)
525
- await new Promise(resolve => setTimeout(resolve, frameIntervalMs));
526
- }
527
- } else {
528
- // AGE-BASED MODE: Dispatch replay-next-age for each age
529
- // Used by LifeRestart and similar games
530
- console.log(`📼 Age-based mode: ${totalAges} ages, ${framesPerAge} frames per age`);
531
-
532
- for (let ageIndex = 0; ageIndex < totalAges; ageIndex++) {
533
- // Advance to next age in the game
534
- await this.page.evaluate(() => {
535
- window.dispatchEvent(new CustomEvent('replay-next-age'));
536
- });
537
-
538
- // Wait a bit for animation
539
- await new Promise(resolve => setTimeout(resolve, 100));
540
-
541
- // Capture frames for this age
542
- for (let f = 0; f < framesPerAge; f++) {
543
- // Check if FFmpeg process has closed unexpectedly
544
- if (ffmpegClosed) {
545
- throw new Error('FFmpeg process closed unexpectedly');
546
- }
547
-
548
- // Capture screenshot as JPEG buffer
549
- const screenshotBuffer = await this.page.screenshot({
550
- type: 'jpeg',
551
- quality: 85,
552
- encoding: 'binary',
553
- }) as Buffer;
554
-
555
- // Write frame to FFmpeg stdin
556
- if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
557
- const writeSuccess = ffmpeg.stdin.write(screenshotBuffer);
558
- if (!writeSuccess) {
559
- // Wait for drain event if buffer is full
560
- await new Promise<void>((resolve) => {
561
- if (ffmpeg.stdin) {
562
- ffmpeg.stdin.once('drain', resolve);
563
- } else {
564
- resolve();
565
- }
566
- });
567
- }
568
- }
569
-
570
- frameIndex++;
571
-
572
- // Update progress
573
- if (frameIndex % config.fps === 0) {
574
- this.reportProgress(
575
- onProgress,
576
- 'capture',
577
- frameIndex,
578
- totalFrames,
579
- `Capturing & encoding frame ${frameIndex}/${totalFrames} (${encoder})`
580
- );
581
- }
582
-
583
- // Small delay between frames
584
- await new Promise(resolve => setTimeout(resolve, frameIntervalMs / 2));
585
- }
586
- }
587
- }
588
-
589
- // Close stdin to signal end of input
590
- if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
591
- ffmpeg.stdin.end();
592
- }
593
-
594
- // Wait for FFmpeg to finish encoding
595
- // The promise was created earlier with event handlers already set up
596
- await encodingPromise;
597
- } catch (error) {
598
- // Cleanup on error
599
- if (!ffmpegClosed) {
600
- ffmpegClosed = true;
601
- if (encodingTimeout) {
602
- clearTimeout(encodingTimeout);
603
- }
604
- if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
605
- ffmpeg.stdin.destroy();
606
- }
607
- if (ffmpeg && !ffmpeg.killed) {
608
- ffmpeg.kill('SIGKILL');
609
- }
610
- }
611
- throw error;
612
- }
613
- }
614
-
615
- /**
616
- * Launch FFmpeg process with appropriate parameters
617
- */
618
- private launchFFmpeg(
619
- encoder: 'h264_nvenc' | 'libx264',
620
- config: RenderConfig & typeof DEFAULT_CONFIG
621
- ): ChildProcess {
622
- const args = [
623
- '-y', // Overwrite output
624
- '-f', 'image2pipe', // Read from stdin
625
- '-vcodec', 'mjpeg', // Input format: JPEG
626
- '-r', String(config.fps), // Input framerate
627
- '-i', '-', // Read from stdin
628
- '-c:v', encoder, // Video encoder
629
- '-preset', 'fast', // Encoding preset
630
- '-crf', '28', // Constant Rate Factor (higher = smaller file, lower quality)
631
- '-pix_fmt', 'yuv420p', // Pixel format for compatibility
632
- '-r', String(config.fps), // Output framerate
633
- '-movflags', '+faststart', // Enable fast start for web playback
634
- config.outputPath,
635
- ];
636
-
637
- // Add NVENC-specific parameters if using hardware encoder
638
- if (encoder === 'h264_nvenc') {
639
- // Insert NVENC-specific options before output path
640
- args.splice(args.length - 1, 0, '-rc', 'vbr', '-cq', '28');
641
- }
642
-
643
- return spawn('ffmpeg', args);
644
- }
645
-
646
- private async closeBrowser(): Promise<void> {
647
- if (this.browser) {
648
- await this.browser.close();
649
- this.browser = null;
650
- this.page = null;
651
- }
652
- }
653
-
654
- private reportProgress(
655
- callback: ((progress: RenderProgress) => void) | undefined,
656
- stage: RenderProgress['stage'],
657
- current: number,
658
- total: number,
659
- message: string
660
- ): void {
661
- if (callback) {
662
- callback({ stage, current, total, message });
663
- }
664
- }
665
- }
@@ -1 +0,0 @@
1
- export * from './PuppeteerRenderer.js';