@agent-foundry/replay-server 1.0.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.
@@ -0,0 +1,512 @@
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
+ import type { ReplayManifestV1 } from '@agent-foundry/replay';
16
+
17
+ export interface RenderConfig {
18
+ /** URL of the game (can be local file:// or http://) */
19
+ gameUrl: string;
20
+
21
+ /** Output video path */
22
+ outputPath: string;
23
+
24
+ /** Video dimensions */
25
+ width?: number;
26
+ height?: number;
27
+
28
+ /** Frames per second */
29
+ fps?: number;
30
+
31
+ /** Seconds to display each age */
32
+ secondsPerAge?: number;
33
+
34
+ /** Enable hardware acceleration (NVENC). Default: false (uses CPU libx264) */
35
+ useHardwareAcceleration?: boolean;
36
+
37
+ /** Temporary directory for screenshots (deprecated, no longer used) */
38
+ tempDir?: string;
39
+
40
+ /** Whether to keep temp files after rendering (deprecated, no longer used) */
41
+ keepTempFiles?: boolean;
42
+
43
+ /** Callback for progress updates */
44
+ onProgress?: (progress: RenderProgress) => void;
45
+ }
46
+
47
+ export interface RenderProgress {
48
+ stage: 'init' | 'capture' | 'encode' | 'complete' | 'error';
49
+ current: number;
50
+ total: number;
51
+ message: string;
52
+ }
53
+
54
+ export interface RenderResult {
55
+ success: boolean;
56
+ outputPath?: string;
57
+ duration?: number;
58
+ error?: string;
59
+ }
60
+
61
+ const DEFAULT_CONFIG = {
62
+ width: 720,
63
+ height: 1280,
64
+ fps: 16,
65
+ secondsPerAge: 1.2,
66
+ keepTempFiles: false,
67
+ useHardwareAcceleration: false, // Default to CPU encoding
68
+ };
69
+
70
+ export class PuppeteerRenderer {
71
+ private browser: Browser | null = null;
72
+ private page: Page | null = null;
73
+ private pageErrors: string[] = [];
74
+ private failedRequests: Array<{ url: string; errorText?: string }> = [];
75
+
76
+ /**
77
+ * Render a replay manifest to video
78
+ */
79
+ async render(manifest: ReplayManifestV1, config: RenderConfig): Promise<RenderResult> {
80
+ const startTime = Date.now();
81
+ const fullConfig: RenderConfig & typeof DEFAULT_CONFIG = {
82
+ ...DEFAULT_CONFIG,
83
+ gameUrl: config.gameUrl,
84
+ outputPath: config.outputPath,
85
+ width: config.width ?? DEFAULT_CONFIG.width,
86
+ height: config.height ?? DEFAULT_CONFIG.height,
87
+ fps: config.fps ?? DEFAULT_CONFIG.fps,
88
+ secondsPerAge: config.secondsPerAge ?? DEFAULT_CONFIG.secondsPerAge,
89
+ useHardwareAcceleration: config.useHardwareAcceleration ?? DEFAULT_CONFIG.useHardwareAcceleration,
90
+ onProgress: config.onProgress,
91
+ };
92
+
93
+ try {
94
+ // Initialize browser
95
+ this.reportProgress(config.onProgress, 'init', 0, 1, 'Launching browser...');
96
+ await this.initBrowser(fullConfig);
97
+
98
+ // Navigate to game
99
+ this.reportProgress(config.onProgress, 'init', 0, 1, 'Loading game...');
100
+ await this.loadGame(fullConfig.gameUrl, manifest);
101
+
102
+ // Capture and encode frames in streaming mode
103
+ const frameCount = manifest.timeline.length * fullConfig.secondsPerAge * fullConfig.fps;
104
+ this.reportProgress(config.onProgress, 'capture', 0, frameCount, 'Starting capture & encode...');
105
+ await this.captureAndEncodeFrames(manifest, fullConfig, config.onProgress);
106
+
107
+ const duration = (Date.now() - startTime) / 1000;
108
+ this.reportProgress(config.onProgress, 'complete', 1, 1, `Completed in ${duration.toFixed(1)}s`);
109
+
110
+ return {
111
+ success: true,
112
+ outputPath: fullConfig.outputPath,
113
+ duration,
114
+ };
115
+ } catch (error) {
116
+ const errorMessage = error instanceof Error ? error.message : String(error);
117
+ this.reportProgress(config.onProgress, 'error', 0, 1, errorMessage);
118
+
119
+ return {
120
+ success: false,
121
+ error: errorMessage,
122
+ };
123
+ } finally {
124
+ await this.closeBrowser();
125
+ }
126
+ }
127
+
128
+ private async initBrowser(config: RenderConfig & typeof DEFAULT_CONFIG): Promise<void> {
129
+ this.browser = await puppeteer.launch({
130
+ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium',
131
+ headless: true,
132
+ timeout: 120_000, // 2 minutes for cold starts
133
+ dumpio: true, // CRITICAL: logs chromium stderr/stdout
134
+ args: [
135
+ '--no-sandbox',
136
+ '--disable-setuid-sandbox',
137
+ '--disable-dev-shm-usage', // /dev/shm is small in containers
138
+ '--no-zygote', // no zygote process in containers
139
+ '--single-process', // safer in containerized environments
140
+ '--disable-gpu',
141
+ '--disable-software-rasterizer',
142
+ '--disable-background-networking',
143
+ '--disable-default-apps',
144
+ '--disable-extensions',
145
+ '--disable-sync',
146
+ '--disable-translate',
147
+ '--metrics-recording-only',
148
+ '--mute-audio',
149
+ '--no-first-run',
150
+ '--safebrowsing-disable-auto-update',
151
+ `--window-size=${config.width},${config.height}`,
152
+ ],
153
+ });
154
+
155
+ this.page = await this.browser.newPage();
156
+ await this.page.setViewport({
157
+ width: config.width,
158
+ height: config.height,
159
+ deviceScaleFactor: 1,
160
+ });
161
+
162
+ // Set up error tracking
163
+ this.pageErrors = [];
164
+ this.failedRequests = [];
165
+
166
+ this.page.on('pageerror', (error) => {
167
+ this.pageErrors.push(error.message);
168
+ console.error('📼 Page error:', error.message);
169
+ });
170
+
171
+ this.page.on('requestfailed', (request) => {
172
+ const failure = request.failure();
173
+ this.failedRequests.push({
174
+ url: request.url(),
175
+ errorText: failure?.errorText,
176
+ });
177
+ console.error('📼 Request failed:', request.url(), failure?.errorText);
178
+ });
179
+ }
180
+
181
+ private async loadGame(gameUrl: string, manifest: ReplayManifestV1): Promise<void> {
182
+ if (!this.page) throw new Error('Browser not initialized');
183
+
184
+ // Navigate to game with replay mode query parameter
185
+ const url = new URL(gameUrl);
186
+ url.searchParams.set('mode', 'replay');
187
+
188
+ console.log(`📼 Navigating to ${url.toString()}`);
189
+
190
+ await this.page.goto(url.toString(), {
191
+ waitUntil: 'networkidle0',
192
+ timeout: 30000,
193
+ });
194
+
195
+ console.log('📼 Page loaded, waiting for React to initialize...');
196
+
197
+ // Wait for React to initialize (check for the app container)
198
+ try {
199
+ await this.page.waitForFunction(() => {
200
+ // Wait for the app to be mounted and initial loading to complete
201
+ const app = document.querySelector('ion-app');
202
+ const loading = document.querySelector('ion-spinner');
203
+ return app !== null && loading === null;
204
+ }, { timeout: 15000 });
205
+ } catch (error) {
206
+ // Log errors before failing
207
+ if (this.pageErrors.length > 0) {
208
+ console.error('📼 Page errors detected:', this.pageErrors);
209
+ }
210
+ if (this.failedRequests.length > 0) {
211
+ console.error('📼 Failed requests:', this.failedRequests);
212
+ }
213
+ throw error;
214
+ }
215
+
216
+ // Log any errors that occurred (but didn't prevent initialization)
217
+ if (this.pageErrors.length > 0) {
218
+ console.warn('📼 Page errors detected (non-fatal):', this.pageErrors);
219
+ }
220
+ if (this.failedRequests.length > 0) {
221
+ console.warn('📼 Failed requests (non-fatal):', this.failedRequests);
222
+ }
223
+
224
+ console.log('📼 React initialized, injecting manifest...');
225
+
226
+ // Inject the replay manifest with retry logic
227
+ const maxRetries = 3;
228
+ let retryCount = 0;
229
+ let success = false;
230
+
231
+ while (retryCount < maxRetries && !success) {
232
+ // Inject the replay manifest
233
+ await this.page.evaluate((manifestJson: string) => {
234
+ // Store manifest in window for the game to read
235
+ (window as unknown as { __REPLAY_MANIFEST__: unknown }).__REPLAY_MANIFEST__ = JSON.parse(manifestJson);
236
+
237
+ console.log('📼 Manifest injected, dispatching event...');
238
+
239
+ // Dispatch event to notify the game
240
+ window.dispatchEvent(new CustomEvent('replay-manifest-loaded', {
241
+ detail: JSON.parse(manifestJson)
242
+ }));
243
+ }, JSON.stringify(manifest));
244
+
245
+ // Wait for the replay ready indicator
246
+ try {
247
+ await this.page.waitForSelector('[data-replay-ready="true"]', {
248
+ timeout: 5000,
249
+ });
250
+ success = true;
251
+ console.log('📼 Replay mode ready!');
252
+ } catch {
253
+ retryCount++;
254
+ console.log(`📼 Retry ${retryCount}/${maxRetries}: Waiting for replay ready...`);
255
+
256
+ if (retryCount < maxRetries) {
257
+ // Small delay before retry
258
+ await new Promise(resolve => setTimeout(resolve, 500));
259
+ }
260
+ }
261
+ }
262
+
263
+ if (!success) {
264
+ console.warn('📼 Warning: Could not confirm replay ready state, proceeding anyway...');
265
+ // Give the app a bit more time to process
266
+ await new Promise(resolve => setTimeout(resolve, 2000));
267
+ }
268
+
269
+ // Additional wait for UI to stabilize
270
+ await new Promise(resolve => setTimeout(resolve, 500));
271
+ }
272
+
273
+ /**
274
+ * Capture frames and encode video in streaming mode using FFmpeg stdin
275
+ * Frames are captured as JPEG buffers and streamed directly to FFmpeg
276
+ */
277
+ private async captureAndEncodeFrames(
278
+ manifest: ReplayManifestV1,
279
+ config: RenderConfig & typeof DEFAULT_CONFIG,
280
+ onProgress?: (progress: RenderProgress) => void
281
+ ): Promise<void> {
282
+ // Use encoder based on configuration
283
+ const encoder = config.useHardwareAcceleration ? 'h264_nvenc' : 'libx264';
284
+ console.log(`📼 Using encoder: ${encoder} (Hardware acceleration: ${config.useHardwareAcceleration ? 'enabled' : 'disabled'})`);
285
+
286
+ await this.captureAndEncodeFramesWithEncoder(manifest, config, encoder, onProgress);
287
+ }
288
+
289
+ /**
290
+ * Internal method to capture and encode with a specific encoder
291
+ */
292
+ private async captureAndEncodeFramesWithEncoder(
293
+ manifest: ReplayManifestV1,
294
+ config: RenderConfig & typeof DEFAULT_CONFIG,
295
+ encoder: 'h264_nvenc' | 'libx264',
296
+ onProgress?: (progress: RenderProgress) => void
297
+ ): Promise<void> {
298
+ if (!this.page) throw new Error('Browser not initialized');
299
+
300
+ const totalAges = manifest.timeline.length;
301
+ const framesPerAge = config.secondsPerAge * config.fps;
302
+ const totalFrames = totalAges * framesPerAge;
303
+
304
+ console.log(`📼 Using encoder: ${encoder}`);
305
+
306
+ // Launch FFmpeg process with stdin input
307
+ const ffmpeg = this.launchFFmpeg(encoder, config);
308
+
309
+ let frameIndex = 0;
310
+ let ffmpegStderr = '';
311
+
312
+ // Create promise for waiting FFmpeg completion
313
+ let ffmpegResolve: (() => void) | null = null;
314
+ let ffmpegReject: ((err: Error) => void) | null = null;
315
+ let ffmpegClosed = false;
316
+ let encodingTimeout: NodeJS.Timeout | null = null;
317
+
318
+ // Create promise that will be resolved when FFmpeg finishes
319
+ const encodingPromise = new Promise<void>((resolve, reject) => {
320
+ ffmpegResolve = resolve;
321
+ ffmpegReject = reject;
322
+
323
+ // Set timeout to prevent hanging (5 minutes should be enough for encoding)
324
+ encodingTimeout = setTimeout(() => {
325
+ if (!ffmpegClosed) {
326
+ ffmpegClosed = true;
327
+ const timeoutError = new Error(`FFmpeg encoding timeout after 5 minutes. Process may be stuck.`);
328
+ reject(timeoutError);
329
+ // Force kill if still running
330
+ if (ffmpeg && !ffmpeg.killed) {
331
+ ffmpeg.kill('SIGKILL');
332
+ }
333
+ }
334
+ }, 5 * 60 * 1000); // 5 minutes timeout
335
+ });
336
+
337
+ // Handle FFmpeg stderr output (for logging and debugging)
338
+ ffmpeg.stderr?.on('data', (data: Buffer) => {
339
+ const output = data.toString();
340
+ ffmpegStderr += output;
341
+ });
342
+
343
+ // Handle FFmpeg process errors
344
+ ffmpeg.on('error', (err: Error) => {
345
+ if (ffmpegReject && !ffmpegClosed) {
346
+ ffmpegClosed = true;
347
+ if (encodingTimeout) clearTimeout(encodingTimeout);
348
+ ffmpegReject(new Error(`Failed to start FFmpeg: ${err.message}`));
349
+ }
350
+ });
351
+
352
+ // Set up close event handler BEFORE starting capture
353
+ ffmpeg.on('close', (code: number) => {
354
+ if (ffmpegClosed) return; // Prevent multiple calls
355
+ ffmpegClosed = true;
356
+
357
+ if (encodingTimeout) {
358
+ clearTimeout(encodingTimeout);
359
+ }
360
+
361
+ if (code === 0) {
362
+ if (ffmpegResolve) {
363
+ ffmpegResolve();
364
+ }
365
+ } else {
366
+ const error = new Error(`FFmpeg exited with code ${code}. Stderr: ${ffmpegStderr}`);
367
+ if (ffmpegReject) {
368
+ ffmpegReject(error);
369
+ }
370
+ }
371
+ });
372
+
373
+ // Wait for FFmpeg stdin to be ready
374
+ if (!ffmpeg.stdin) {
375
+ throw new Error('FFmpeg stdin not available');
376
+ }
377
+
378
+ try {
379
+ // Capture and stream frames
380
+ for (let ageIndex = 0; ageIndex < totalAges; ageIndex++) {
381
+ // Advance to next age in the game
382
+ await this.page.evaluate(() => {
383
+ window.dispatchEvent(new CustomEvent('replay-next-age'));
384
+ });
385
+
386
+ // Wait a bit for animation
387
+ await new Promise(resolve => setTimeout(resolve, 100));
388
+
389
+ // Capture frames for this age
390
+ for (let f = 0; f < framesPerAge; f++) {
391
+ // Check if FFmpeg process has closed unexpectedly
392
+ if (ffmpegClosed) {
393
+ throw new Error('FFmpeg process closed unexpectedly');
394
+ }
395
+
396
+ // Capture screenshot as JPEG buffer
397
+ const screenshotBuffer = await this.page.screenshot({
398
+ type: 'jpeg',
399
+ quality: 85,
400
+ encoding: 'binary',
401
+ }) as Buffer;
402
+
403
+ // Write frame to FFmpeg stdin
404
+ if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
405
+ const writeSuccess = ffmpeg.stdin.write(screenshotBuffer);
406
+ if (!writeSuccess) {
407
+ // Wait for drain event if buffer is full
408
+ await new Promise<void>((resolve) => {
409
+ if (ffmpeg.stdin) {
410
+ ffmpeg.stdin.once('drain', resolve);
411
+ } else {
412
+ resolve();
413
+ }
414
+ });
415
+ }
416
+ }
417
+
418
+ frameIndex++;
419
+
420
+ // Update progress
421
+ if (frameIndex % config.fps === 0) {
422
+ this.reportProgress(
423
+ onProgress,
424
+ 'capture',
425
+ frameIndex,
426
+ totalFrames,
427
+ `Capturing & encoding frame ${frameIndex}/${totalFrames} (${encoder})`
428
+ );
429
+ }
430
+
431
+ // Small delay between frames
432
+ await new Promise(resolve => setTimeout(resolve, 1000 / config.fps / 2));
433
+ }
434
+ }
435
+
436
+ // Close stdin to signal end of input
437
+ if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
438
+ ffmpeg.stdin.end();
439
+ }
440
+
441
+ // Wait for FFmpeg to finish encoding
442
+ // The promise was created earlier with event handlers already set up
443
+ await encodingPromise;
444
+ } catch (error) {
445
+ // Cleanup on error
446
+ if (!ffmpegClosed) {
447
+ ffmpegClosed = true;
448
+ if (encodingTimeout) {
449
+ clearTimeout(encodingTimeout);
450
+ }
451
+ if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
452
+ ffmpeg.stdin.destroy();
453
+ }
454
+ if (ffmpeg && !ffmpeg.killed) {
455
+ ffmpeg.kill('SIGKILL');
456
+ }
457
+ }
458
+ throw error;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Launch FFmpeg process with appropriate parameters
464
+ */
465
+ private launchFFmpeg(
466
+ encoder: 'h264_nvenc' | 'libx264',
467
+ config: RenderConfig & typeof DEFAULT_CONFIG
468
+ ): ChildProcess {
469
+ const args = [
470
+ '-y', // Overwrite output
471
+ '-f', 'image2pipe', // Read from stdin
472
+ '-vcodec', 'mjpeg', // Input format: JPEG
473
+ '-r', String(config.fps), // Input framerate
474
+ '-i', '-', // Read from stdin
475
+ '-c:v', encoder, // Video encoder
476
+ '-preset', 'fast', // Encoding preset
477
+ '-crf', '28', // Constant Rate Factor (higher = smaller file, lower quality)
478
+ '-pix_fmt', 'yuv420p', // Pixel format for compatibility
479
+ '-r', String(config.fps), // Output framerate
480
+ '-movflags', '+faststart', // Enable fast start for web playback
481
+ config.outputPath,
482
+ ];
483
+
484
+ // Add NVENC-specific parameters if using hardware encoder
485
+ if (encoder === 'h264_nvenc') {
486
+ // Insert NVENC-specific options before output path
487
+ args.splice(args.length - 1, 0, '-rc', 'vbr', '-cq', '28');
488
+ }
489
+
490
+ return spawn('ffmpeg', args);
491
+ }
492
+
493
+ private async closeBrowser(): Promise<void> {
494
+ if (this.browser) {
495
+ await this.browser.close();
496
+ this.browser = null;
497
+ this.page = null;
498
+ }
499
+ }
500
+
501
+ private reportProgress(
502
+ callback: ((progress: RenderProgress) => void) | undefined,
503
+ stage: RenderProgress['stage'],
504
+ current: number,
505
+ total: number,
506
+ message: string
507
+ ): void {
508
+ if (callback) {
509
+ callback({ stage, current, total, message });
510
+ }
511
+ }
512
+ }
@@ -0,0 +1 @@
1
+ export * from './PuppeteerRenderer.js';