@agent-foundry/replay-server 1.0.0 → 1.0.2

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 (46) hide show
  1. package/.cursor/dev.mdc +941 -0
  2. package/.cursor/project.mdc +17 -2
  3. package/.env +30 -0
  4. package/Dockerfile +6 -0
  5. package/README.md +297 -27
  6. package/dist/cli/render.js +14 -4
  7. package/dist/cli/render.js.map +1 -1
  8. package/dist/renderer/PuppeteerRenderer.d.ts +28 -2
  9. package/dist/renderer/PuppeteerRenderer.d.ts.map +1 -1
  10. package/dist/renderer/PuppeteerRenderer.js +134 -36
  11. package/dist/renderer/PuppeteerRenderer.js.map +1 -1
  12. package/dist/server/index.d.ts +4 -0
  13. package/dist/server/index.d.ts.map +1 -1
  14. package/dist/server/index.js +200 -46
  15. package/dist/server/index.js.map +1 -1
  16. package/dist/services/BundleManager.d.ts +99 -0
  17. package/dist/services/BundleManager.d.ts.map +1 -0
  18. package/dist/services/BundleManager.js +410 -0
  19. package/dist/services/BundleManager.js.map +1 -0
  20. package/dist/services/OSSClient.d.ts +51 -0
  21. package/dist/services/OSSClient.d.ts.map +1 -0
  22. package/dist/services/OSSClient.js +207 -0
  23. package/dist/services/OSSClient.js.map +1 -0
  24. package/dist/services/index.d.ts +7 -0
  25. package/dist/services/index.d.ts.map +1 -0
  26. package/dist/services/index.js +7 -0
  27. package/dist/services/index.js.map +1 -0
  28. package/dist/services/types.d.ts +73 -0
  29. package/dist/services/types.d.ts.map +1 -0
  30. package/dist/services/types.js +5 -0
  31. package/dist/services/types.js.map +1 -0
  32. package/docker-compose.local.yml +10 -0
  33. package/env.example +30 -0
  34. package/package.json +7 -3
  35. package/restart.sh +5 -0
  36. package/samples/jump_arena_5_ja-mksi5fku-qgk5iq.json +1952 -0
  37. package/scripts/render-pipeline.sh +657 -0
  38. package/scripts/test-bundle-preload.sh +20 -0
  39. package/scripts/test-service-sts.sh +176 -0
  40. package/src/cli/render.ts +18 -7
  41. package/src/renderer/PuppeteerRenderer.ts +192 -39
  42. package/src/server/index.ts +249 -68
  43. package/src/services/BundleManager.ts +503 -0
  44. package/src/services/OSSClient.ts +286 -0
  45. package/src/services/index.ts +7 -0
  46. package/src/services/types.ts +78 -0
@@ -0,0 +1,941 @@
1
+ ---
2
+ alwaysApply: true
3
+ ---
4
+
5
+ # Replay Server - Development Rules
6
+
7
+ ## TypeScript Guidelines
8
+
9
+ ### Configuration
10
+
11
+ - **Strict Mode**: Enabled in `tsconfig.json`
12
+ - `strict: true`
13
+ - `noImplicitAny: true`
14
+ - `strictNullChecks: true`
15
+ - **ES Modules**: `"type": "module"` in `package.json`
16
+ - **Target**: ES2022
17
+ - **Module Resolution**: `bundler` (for ESM)
18
+
19
+ ### Type Safety
20
+
21
+ - **Always use types**: Avoid `any`, use `unknown` when type is truly unknown
22
+ - **Explicit return types**: For public methods, specify return types
23
+ - **Type inference**: Use inference for local variables when type is obvious
24
+
25
+ ```typescript
26
+ // Good: Explicit return type for public method
27
+ async render(manifest: ReplayManifestV1, config: RenderConfig): Promise<RenderResult> {
28
+ // ...
29
+ }
30
+
31
+ // Good: Type inference for local variable
32
+ const jobId = uuidv4(); // Inferred as string
33
+
34
+ // Bad: Using any
35
+ function process(data: any) { }
36
+
37
+ // Good: Proper typing
38
+ function process(data: ReplayManifestV1) { }
39
+ ```
40
+
41
+ ### Interfaces vs Types
42
+
43
+ - **Interfaces**: For object shapes that might be extended
44
+ - **Types**: For unions, intersections, computed types
45
+
46
+ ```typescript
47
+ // Good: Interface for extensible object
48
+ interface RenderConfig {
49
+ gameUrl: string;
50
+ outputPath: string;
51
+ width?: number;
52
+ }
53
+
54
+ // Good: Type for union
55
+ type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
56
+
57
+ // Good: Type for computed
58
+ type RenderProgressStage = RenderProgress['stage'];
59
+ ```
60
+
61
+ ### Null Safety
62
+
63
+ - **Optional properties**: Use `?` for optional properties
64
+ - **Null checks**: Always check for null/undefined before use
65
+ - **Non-null assertion**: Avoid `!` operator, use proper checks
66
+
67
+ ```typescript
68
+ // Good: Optional chaining
69
+ if (job?.progress) {
70
+ console.log(job.progress.current);
71
+ }
72
+
73
+ // Good: Null check
74
+ if (!this.page) {
75
+ throw new Error('Browser not initialized');
76
+ }
77
+
78
+ // Bad: Non-null assertion
79
+ this.page!.screenshot();
80
+ ```
81
+
82
+ ### Enums
83
+
84
+ - **Avoid enums**: Use union types instead
85
+ - **String literals**: Prefer `'pending' | 'processing'` over `enum JobStatus`
86
+
87
+ ---
88
+
89
+ ## Express Patterns
90
+
91
+ ### Route Handlers
92
+
93
+ - **Async functions**: Always use async/await
94
+ - **Error handling**: Wrap in try/catch, return consistent error format
95
+ - **Request validation**: Check required fields early
96
+ - **Logging**: Use console.log with prefixes for debugging
97
+
98
+ ```typescript
99
+ // Good: Async handler with error handling
100
+ app.post('/render', async (req, res) => {
101
+ console.log('[POST /render] Request received');
102
+ try {
103
+ const { manifest, config } = req.body;
104
+
105
+ // Validate early
106
+ if (!manifest && !req.body.manifestUrl) {
107
+ return res.status(400).json({ error: 'Either manifest or manifestUrl is required' });
108
+ }
109
+
110
+ // Process request
111
+ const jobId = uuidv4();
112
+ // ...
113
+
114
+ res.json({ jobId, status: 'pending' });
115
+ } catch (error) {
116
+ console.error('[POST /render] Error:', error);
117
+ res.status(500).json({ error: 'Internal server error' });
118
+ }
119
+ });
120
+ ```
121
+
122
+ ### Error Responses
123
+
124
+ - **Consistent format**: Always return JSON with `error` field
125
+ - **Status codes**: Use appropriate HTTP status codes
126
+ - `400` - Bad request (validation errors)
127
+ - `404` - Not found
128
+ - `500` - Internal server error
129
+
130
+ ```typescript
131
+ // Good: Consistent error format
132
+ res.status(400).json({ error: 'Invalid manifest schema' });
133
+
134
+ // Good: Descriptive error messages
135
+ res.status(404).json({ error: 'Job not found' });
136
+ ```
137
+
138
+ ### Middleware
139
+
140
+ - **CORS**: Enable for all routes
141
+ - **JSON parsing**: Set limit for large manifests (10mb)
142
+ - **Static files**: Serve bundles after API routes
143
+
144
+ ```typescript
145
+ app.use(cors());
146
+ app.use(express.json({ limit: '10mb' }));
147
+
148
+ // API routes first
149
+ app.get('/bundles', ...);
150
+ app.post('/render', ...);
151
+
152
+ // Static files after API routes
153
+ app.use('/bundles', express.static(BUNDLES_DIR));
154
+ ```
155
+
156
+ ### Logging
157
+
158
+ - **Prefix logs**: Use `[ENDPOINT]` or `[COMPONENT]` prefix
159
+ - **Structured logging**: Include relevant context
160
+ - **Error logging**: Log full error details
161
+
162
+ ```typescript
163
+ // Good: Prefixed logging
164
+ console.log('[POST /render] Request received');
165
+ console.log('[POST /render] Render job started, jobId:', jobId);
166
+ console.error('[POST /render] Error starting render job:', error);
167
+ ```
168
+
169
+ ---
170
+
171
+ ## Puppeteer Patterns
172
+
173
+ ### Browser Initialization
174
+
175
+ - **Reuse instance**: Create browser once, reuse for multiple renders
176
+ - **Close properly**: Always close browser in finally block
177
+ - **Configuration**: Use optimized flags for containerized environments
178
+
179
+ ```typescript
180
+ // Good: Browser initialization with proper cleanup
181
+ private browser: Browser | null = null;
182
+
183
+ async render(...) {
184
+ try {
185
+ await this.initBrowser(config);
186
+ // ... rendering logic
187
+ } finally {
188
+ await this.closeBrowser();
189
+ }
190
+ }
191
+
192
+ private async closeBrowser(): Promise<void> {
193
+ if (this.browser) {
194
+ await this.browser.close();
195
+ this.browser = null;
196
+ this.page = null;
197
+ }
198
+ }
199
+ ```
200
+
201
+ ### Browser Configuration
202
+
203
+ - **Container-friendly**: Use `--single-process`, `--no-zygote`
204
+ - **Memory optimization**: `--disable-dev-shm-usage` for small /dev/shm
205
+ - **Security**: `--no-sandbox`, `--disable-setuid-sandbox` in containers
206
+ - **Performance**: Disable GPU, extensions, background networking
207
+
208
+ ```typescript
209
+ // Good: Optimized browser args
210
+ this.browser = await puppeteer.launch({
211
+ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium',
212
+ headless: true,
213
+ args: [
214
+ '--no-sandbox',
215
+ '--disable-setuid-sandbox',
216
+ '--disable-dev-shm-usage',
217
+ '--no-zygote',
218
+ '--single-process',
219
+ '--disable-gpu',
220
+ // ... more flags
221
+ ],
222
+ });
223
+ ```
224
+
225
+ ### Page Navigation
226
+
227
+ - **Wait strategy**: Use `networkidle0` for full page load
228
+ - **Timeout**: Set reasonable timeout (30s for game load)
229
+ - **Error tracking**: Track page errors and failed requests
230
+
231
+ ```typescript
232
+ // Good: Navigation with proper wait strategy
233
+ await this.page.goto(url.toString(), {
234
+ waitUntil: 'networkidle0',
235
+ timeout: 30000,
236
+ });
237
+
238
+ // Good: Error tracking
239
+ this.page.on('pageerror', (error) => {
240
+ this.pageErrors.push(error.message);
241
+ console.error('📼 Page error:', error.message);
242
+ });
243
+
244
+ this.page.on('requestfailed', (request) => {
245
+ const failure = request.failure();
246
+ this.failedRequests.push({
247
+ url: request.url(),
248
+ errorText: failure?.errorText,
249
+ });
250
+ });
251
+ ```
252
+
253
+ ### Waiting for Game Ready
254
+
255
+ - **React initialization**: Wait for app container and loading spinner
256
+ - **Replay ready**: Wait for `data-replay-ready` attribute
257
+ - **Retry logic**: Implement retry with timeout
258
+
259
+ ```typescript
260
+ // Good: Wait for React initialization
261
+ await this.page.waitForFunction(() => {
262
+ const app = document.querySelector('ion-app');
263
+ const loading = document.querySelector('ion-spinner');
264
+ return app !== null && loading === null;
265
+ }, { timeout: 15000 });
266
+
267
+ // Good: Wait for replay ready with retry
268
+ const maxRetries = 3;
269
+ let retryCount = 0;
270
+ while (retryCount < maxRetries) {
271
+ try {
272
+ await this.page.waitForSelector('[data-replay-ready="true"]', {
273
+ timeout: 5000,
274
+ });
275
+ break;
276
+ } catch {
277
+ retryCount++;
278
+ await new Promise(resolve => setTimeout(resolve, 500));
279
+ }
280
+ }
281
+ ```
282
+
283
+ ### Screenshot Capture
284
+
285
+ - **JPEG format**: Use JPEG for smaller file size
286
+ - **Quality**: 85% quality for good balance
287
+ - **Binary encoding**: Use `encoding: 'binary'` for Buffer
288
+
289
+ ```typescript
290
+ // Good: JPEG screenshot
291
+ const screenshotBuffer = await this.page.screenshot({
292
+ type: 'jpeg',
293
+ quality: 85,
294
+ encoding: 'binary',
295
+ }) as Buffer;
296
+ ```
297
+
298
+ ---
299
+
300
+ ## FFmpeg Integration
301
+
302
+ ### Streaming Mode
303
+
304
+ - **Stdin input**: Use `-f image2pipe -vcodec mjpeg -i -`
305
+ - **No temp files**: Stream frames directly to FFmpeg
306
+ - **Backpressure**: Handle stdin drain events
307
+
308
+ ```typescript
309
+ // Good: Streaming FFmpeg setup
310
+ const ffmpeg = spawn('ffmpeg', [
311
+ '-y',
312
+ '-f', 'image2pipe',
313
+ '-vcodec', 'mjpeg',
314
+ '-r', String(config.fps),
315
+ '-i', '-',
316
+ '-c:v', 'libx264',
317
+ '-preset', 'fast',
318
+ '-crf', '28',
319
+ '-pix_fmt', 'yuv420p',
320
+ '-r', String(config.fps),
321
+ '-movflags', '+faststart',
322
+ config.outputPath,
323
+ ]);
324
+
325
+ // Good: Handle stdin drain
326
+ const writeSuccess = ffmpeg.stdin.write(screenshotBuffer);
327
+ if (!writeSuccess) {
328
+ await new Promise<void>((resolve) => {
329
+ if (ffmpeg.stdin) {
330
+ ffmpeg.stdin.once('drain', resolve);
331
+ } else {
332
+ resolve();
333
+ }
334
+ });
335
+ }
336
+ ```
337
+
338
+ ### Encoder Selection
339
+
340
+ - **Default**: libx264 (CPU encoding, works everywhere)
341
+ - **Optional**: h264_nvenc (GPU encoding, requires NVIDIA GPU)
342
+ - **Configuration**: Use `useHardwareAcceleration` flag
343
+
344
+ ```typescript
345
+ // Good: Encoder selection
346
+ const encoder = config.useHardwareAcceleration ? 'h264_nvenc' : 'libx264';
347
+
348
+ // Good: NVENC-specific parameters
349
+ if (encoder === 'h264_nvenc') {
350
+ args.splice(args.length - 1, 0, '-rc', 'vbr', '-cq', '28');
351
+ }
352
+ ```
353
+
354
+ ### Process Management
355
+
356
+ - **Error handling**: Track stderr, handle process errors
357
+ - **Timeout**: Set timeout to prevent hanging (5 minutes)
358
+ - **Cleanup**: Close stdin, kill process on error
359
+ - **Promise-based**: Use promise with event handlers
360
+
361
+ ```typescript
362
+ // Good: Process management with timeout
363
+ const encodingPromise = new Promise<void>((resolve, reject) => {
364
+ const timeout = setTimeout(() => {
365
+ if (!ffmpegClosed) {
366
+ ffmpegClosed = true;
367
+ reject(new Error('FFmpeg encoding timeout'));
368
+ if (ffmpeg && !ffmpeg.killed) {
369
+ ffmpeg.kill('SIGKILL');
370
+ }
371
+ }
372
+ }, 5 * 60 * 1000); // 5 minutes
373
+
374
+ ffmpeg.on('close', (code) => {
375
+ clearTimeout(timeout);
376
+ if (code === 0) {
377
+ resolve();
378
+ } else {
379
+ reject(new Error(`FFmpeg exited with code ${code}`));
380
+ }
381
+ });
382
+
383
+ ffmpeg.on('error', (err) => {
384
+ clearTimeout(timeout);
385
+ reject(new Error(`Failed to start FFmpeg: ${err.message}`));
386
+ });
387
+ });
388
+
389
+ // Good: Cleanup on error
390
+ try {
391
+ // ... capture frames
392
+ } catch (error) {
393
+ if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
394
+ ffmpeg.stdin.destroy();
395
+ }
396
+ if (ffmpeg && !ffmpeg.killed) {
397
+ ffmpeg.kill('SIGKILL');
398
+ }
399
+ throw error;
400
+ }
401
+ ```
402
+
403
+ ---
404
+
405
+ ## File Organization
406
+
407
+ ### Server Structure
408
+
409
+ ```
410
+ src/server/
411
+ └── index.ts # Express app, all routes, job management
412
+ ```
413
+
414
+ **Pattern**: Single file for server (small enough to maintain). If it grows, split by route prefix:
415
+ - `routes/render.ts` - Render endpoints
416
+ - `routes/jobs.ts` - Job management
417
+ - `routes/bundles.ts` - Bundle serving
418
+
419
+ ### Renderer Structure
420
+
421
+ ```
422
+ src/renderer/
423
+ ├── index.ts # Exports
424
+ └── PuppeteerRenderer.ts # Main renderer class
425
+ ```
426
+
427
+ **Pattern**: Single class with private methods:
428
+ - `render()` - Public entry point
429
+ - `initBrowser()` - Browser initialization
430
+ - `loadGame()` - Game loading and manifest injection
431
+ - `captureAndEncodeFrames()` - Frame capture loop
432
+ - `launchFFmpeg()` - FFmpeg process setup
433
+ - `closeBrowser()` - Cleanup
434
+
435
+ ### CLI Structure
436
+
437
+ ```
438
+ src/cli/
439
+ └── render.ts # CLI entry point, argument parsing, renderer invocation
440
+ ```
441
+
442
+ **Pattern**: Single file with:
443
+ - `parseArgs()` - Command-line argument parsing
444
+ - `loadManifest()` - Manifest loading (file or URL)
445
+ - `formatProgress()` - Progress bar formatting
446
+ - `main()` - Entry point
447
+
448
+ ---
449
+
450
+ ## Naming Conventions
451
+
452
+ ### Classes
453
+
454
+ - **PascalCase**: `PuppeteerRenderer`, `RenderConfig`
455
+ - **Static classes**: Use static methods (no instances needed)
456
+
457
+ ```typescript
458
+ // Good: PascalCase class
459
+ export class PuppeteerRenderer {
460
+ private browser: Browser | null = null;
461
+
462
+ async render(...) { }
463
+ }
464
+ ```
465
+
466
+ ### Functions
467
+
468
+ - **camelCase**: `render`, `initBrowser`, `loadGame`
469
+ - **Verb-based**: `getJob`, `createJob`, `startRender`
470
+ - **Descriptive**: `captureAndEncodeFrames` not `capture`
471
+
472
+ ```typescript
473
+ // Good: Verb-based, descriptive
474
+ async function processJob(job: RenderJob, manifest: ReplayManifestV1) { }
475
+ async function resolveGameUrl(manifest: ReplayManifestV1, configGameUrl?: string) { }
476
+ ```
477
+
478
+ ### Variables
479
+
480
+ - **camelCase**: `jobId`, `outputPath`, `frameIndex`
481
+ - **Constants**: `UPPER_SNAKE_CASE`: `DEFAULT_CONFIG`, `MAX_RETRIES`
482
+
483
+ ```typescript
484
+ // Good: camelCase variables
485
+ const jobId = uuidv4();
486
+ const outputPath = path.join(OUTPUT_DIR, `${jobId}.mp4`);
487
+
488
+ // Good: UPPER_SNAKE_CASE constants
489
+ const DEFAULT_CONFIG = {
490
+ width: 720,
491
+ height: 1280,
492
+ fps: 16,
493
+ };
494
+ ```
495
+
496
+ ### Interfaces/Types
497
+
498
+ - **PascalCase**: `RenderConfig`, `RenderProgress`, `RenderResult`
499
+ - **No "I" prefix**: Use `RenderConfig` not `IRenderConfig`
500
+ - **Descriptive names**: `RenderProgress` not `Progress`
501
+
502
+ ```typescript
503
+ // Good: PascalCase, descriptive
504
+ interface RenderConfig {
505
+ gameUrl: string;
506
+ outputPath: string;
507
+ }
508
+
509
+ type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';
510
+ ```
511
+
512
+ ---
513
+
514
+ ## Error Handling
515
+
516
+ ### Explicit Errors
517
+
518
+ - **Throw with context**: Include relevant information in error message
519
+ - **Error types**: Use Error class, not strings
520
+ - **Propagate errors**: Let errors bubble up with context
521
+
522
+ ```typescript
523
+ // Good: Descriptive error
524
+ if (!manifest.bundleId && !configGameUrl) {
525
+ throw new Error('Bundle not found and no gameUrl provided');
526
+ }
527
+
528
+ // Good: Error with context
529
+ if (code === 0) {
530
+ resolve();
531
+ } else {
532
+ reject(new Error(`FFmpeg exited with code ${code}. Stderr: ${ffmpegStderr}`));
533
+ }
534
+
535
+ // Bad: Generic error
536
+ throw new Error('Error');
537
+ ```
538
+
539
+ ### Progress Reporting
540
+
541
+ - **Callback pattern**: Use optional callback for progress updates
542
+ - **Structured progress**: Include stage, current, total, message
543
+ - **Update frequency**: Don't update on every frame (use modulo)
544
+
545
+ ```typescript
546
+ // Good: Progress callback pattern
547
+ interface RenderConfig {
548
+ onProgress?: (progress: RenderProgress) => void;
549
+ }
550
+
551
+ private reportProgress(
552
+ callback: ((progress: RenderProgress) => void) | undefined,
553
+ stage: RenderProgress['stage'],
554
+ current: number,
555
+ total: number,
556
+ message: string
557
+ ): void {
558
+ if (callback) {
559
+ callback({ stage, current, total, message });
560
+ }
561
+ }
562
+
563
+ // Good: Update frequency
564
+ if (frameIndex % config.fps === 0) {
565
+ this.reportProgress(onProgress, 'capture', frameIndex, totalFrames, message);
566
+ }
567
+ ```
568
+
569
+ ### Job Status Tracking
570
+
571
+ - **State machine**: Track pending → processing → completed/failed
572
+ - **Error storage**: Store error message in job object
573
+ - **Timestamps**: Track createdAt and completedAt
574
+
575
+ ```typescript
576
+ // Good: Job status tracking
577
+ interface RenderJob {
578
+ id: string;
579
+ status: 'pending' | 'processing' | 'completed' | 'failed';
580
+ progress: RenderProgress | null;
581
+ outputPath: string | null;
582
+ error: string | null;
583
+ createdAt: Date;
584
+ completedAt: Date | null;
585
+ }
586
+
587
+ // Good: Status updates
588
+ job.status = 'processing';
589
+ try {
590
+ // ... render
591
+ job.status = 'completed';
592
+ job.outputPath = result.outputPath;
593
+ } catch (error) {
594
+ job.status = 'failed';
595
+ job.error = error instanceof Error ? error.message : String(error);
596
+ } finally {
597
+ job.completedAt = new Date();
598
+ }
599
+ ```
600
+
601
+ ---
602
+
603
+ ## Dependencies
604
+
605
+ ### Core Dependencies
606
+
607
+ - **express**: HTTP server framework
608
+ - **puppeteer**: Headless browser automation
609
+ - **fluent-ffmpeg**: FFmpeg wrapper (note: actual FFmpeg binary required)
610
+ - **cors**: CORS middleware
611
+ - **uuid**: Job ID generation
612
+
613
+ ### Type Dependencies
614
+
615
+ Always include `@types/*` packages for TypeScript support:
616
+ - `@types/express`
617
+ - `@types/fluent-ffmpeg`
618
+ - `@types/cors`
619
+ - `@types/uuid`
620
+ - `@types/node`
621
+
622
+ ### Shared Package
623
+
624
+ - **@agent-foundry/replay**: Replay manifest type definitions
625
+ - Import types: `import type { ReplayManifestV1 } from '@agent-foundry/replay'`
626
+ - Used for manifest validation and type safety
627
+
628
+ ### Package Management
629
+
630
+ - **Always use pnpm**: This is a pnpm monorepo workspace
631
+ - **Run from root**: Use `pnpm --filter replay-server` from monorepo root
632
+ - **CLI tools**: Use `pnpm exec <command>` for local CLI tools
633
+
634
+ ---
635
+
636
+ ## Code Organization
637
+
638
+ ### Import Order
639
+
640
+ 1. Node.js built-ins
641
+ 2. Third-party libraries
642
+ 3. Shared packages
643
+ 4. Local imports
644
+ 5. Type imports (use `import type`)
645
+
646
+ ```typescript
647
+ // Good: Organized imports
648
+ import * as fs from 'fs';
649
+ import * as path from 'path';
650
+ import express, { type Express } from 'express';
651
+ import cors from 'cors';
652
+ import { v4 as uuidv4 } from 'uuid';
653
+ import { PuppeteerRenderer, RenderProgress } from '../renderer/PuppeteerRenderer.js';
654
+ import type { ReplayManifestV1 } from '@agent-foundry/replay';
655
+ ```
656
+
657
+ ### Export Strategy
658
+
659
+ - **Named exports**: For classes, functions, types
660
+ - **Default exports**: Avoid (not needed for this project)
661
+ - **Barrel exports**: Use `index.ts` for clean imports
662
+
663
+ ```typescript
664
+ // src/renderer/index.ts
665
+ export * from './PuppeteerRenderer.js';
666
+
667
+ // Usage
668
+ import { PuppeteerRenderer } from '../renderer';
669
+ ```
670
+
671
+ ### File Headers
672
+
673
+ - **Purpose**: Add file header comment explaining purpose
674
+ - **Key exports**: List main exports in header
675
+
676
+ ```typescript
677
+ /**
678
+ * Puppeteer-based Video Renderer
679
+ *
680
+ * This renderer:
681
+ * 1. Launches a headless browser
682
+ * 2. Opens the game at a special replay URL
683
+ * 3. Injects the replay manifest
684
+ * 4. Captures screenshots as JPEG buffers and streams directly to FFmpeg via stdin
685
+ * 5. Encodes video using libx264 (CPU) by default, or h264_nvenc if explicitly enabled
686
+ */
687
+ ```
688
+
689
+ ---
690
+
691
+ ## Common Patterns
692
+
693
+ ### Pattern: Game URL Resolution
694
+
695
+ ```typescript
696
+ function resolveGameUrl(manifest: ReplayManifestV1, configGameUrl?: string): string {
697
+ // 1. Explicit gameUrl in config takes precedence
698
+ if (configGameUrl) {
699
+ return configGameUrl;
700
+ }
701
+
702
+ // 2. Use bundleId from manifest to serve from local bundles
703
+ if (manifest.bundleId) {
704
+ const bundlePath = path.join(BUNDLES_DIR, manifest.bundleId);
705
+ if (!fs.existsSync(bundlePath)) {
706
+ throw new Error(`Bundle not found: ${manifest.bundleId}`);
707
+ }
708
+ return `http://localhost:${PORT}/bundles/${manifest.bundleId}/`;
709
+ }
710
+
711
+ // 3. Fall back to default GAME_URL
712
+ return GAME_URL;
713
+ }
714
+ ```
715
+
716
+ ### Pattern: Manifest Injection
717
+
718
+ ```typescript
719
+ await this.page.evaluate((manifestJson: string) => {
720
+ // Store manifest in window for the game to read
721
+ (window as unknown as { __REPLAY_MANIFEST__: unknown }).__REPLAY_MANIFEST__ = JSON.parse(manifestJson);
722
+
723
+ // Dispatch event to notify the game
724
+ window.dispatchEvent(new CustomEvent('replay-manifest-loaded', {
725
+ detail: JSON.parse(manifestJson)
726
+ }));
727
+ }, JSON.stringify(manifest));
728
+ ```
729
+
730
+ ### Pattern: Frame Capture Loop
731
+
732
+ ```typescript
733
+ for (let ageIndex = 0; ageIndex < totalAges; ageIndex++) {
734
+ // Advance to next age
735
+ await this.page.evaluate(() => {
736
+ window.dispatchEvent(new CustomEvent('replay-next-age'));
737
+ });
738
+
739
+ // Wait for animation
740
+ await new Promise(resolve => setTimeout(resolve, 100));
741
+
742
+ // Capture frames for this age
743
+ for (let f = 0; f < framesPerAge; f++) {
744
+ const screenshotBuffer = await this.page.screenshot({
745
+ type: 'jpeg',
746
+ quality: 85,
747
+ encoding: 'binary',
748
+ }) as Buffer;
749
+
750
+ // Write to FFmpeg stdin
751
+ if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
752
+ ffmpeg.stdin.write(screenshotBuffer);
753
+ }
754
+
755
+ // Small delay between frames
756
+ await new Promise(resolve => setTimeout(resolve, 1000 / config.fps / 2));
757
+ }
758
+ }
759
+ ```
760
+
761
+ ---
762
+
763
+ ## Anti-Patterns to Avoid
764
+
765
+ ### ❌ Direct Browser Access Without Cleanup
766
+
767
+ ```typescript
768
+ // BAD: Browser not closed
769
+ async function render() {
770
+ const browser = await puppeteer.launch();
771
+ const page = await browser.newPage();
772
+ // ... render
773
+ // Browser never closed!
774
+ }
775
+
776
+ // GOOD: Always close in finally
777
+ async function render() {
778
+ let browser: Browser | null = null;
779
+ try {
780
+ browser = await puppeteer.launch();
781
+ // ... render
782
+ } finally {
783
+ if (browser) {
784
+ await browser.close();
785
+ }
786
+ }
787
+ }
788
+ ```
789
+
790
+ ### ❌ Mutating Job State Directly
791
+
792
+ ```typescript
793
+ // BAD: Direct mutation
794
+ job.status = 'completed';
795
+ job.outputPath = outputPath;
796
+
797
+ // GOOD: Update through setter or immutable update
798
+ jobs.set(jobId, {
799
+ ...job,
800
+ status: 'completed',
801
+ outputPath: outputPath,
802
+ });
803
+ ```
804
+
805
+ ### ❌ Ignoring FFmpeg Errors
806
+
807
+ ```typescript
808
+ // BAD: No error handling
809
+ const ffmpeg = spawn('ffmpeg', args);
810
+ ffmpeg.on('close', () => {
811
+ // Assume success
812
+ });
813
+
814
+ // GOOD: Track errors and exit codes
815
+ let ffmpegStderr = '';
816
+ ffmpeg.stderr?.on('data', (data: Buffer) => {
817
+ ffmpegStderr += data.toString();
818
+ });
819
+
820
+ ffmpeg.on('close', (code) => {
821
+ if (code !== 0) {
822
+ throw new Error(`FFmpeg exited with code ${code}. Stderr: ${ffmpegStderr}`);
823
+ }
824
+ });
825
+ ```
826
+
827
+ ### ❌ Using Any Type
828
+
829
+ ```typescript
830
+ // BAD: Using any
831
+ function process(data: any) { }
832
+
833
+ // GOOD: Proper typing
834
+ function process(data: ReplayManifestV1) { }
835
+ ```
836
+
837
+ ---
838
+
839
+ ## Code Review Checklist
840
+
841
+ - [ ] Follows TypeScript strict mode (no `any`)
842
+ - [ ] Proper error handling (try/catch, error messages)
843
+ - [ ] Browser cleanup in finally block
844
+ - [ ] FFmpeg process properly managed (timeout, cleanup)
845
+ - [ ] Logging with prefixes for debugging
846
+ - [ ] Consistent error response format
847
+ - [ ] Request validation early in handlers
848
+ - [ ] Follows naming conventions
849
+ - [ ] Proper file organization
850
+ - [ ] Comments for complex logic
851
+ - [ ] No unused imports/variables
852
+
853
+ ---
854
+
855
+ ## Performance Considerations
856
+
857
+ ### Browser Optimization
858
+
859
+ - **Single process**: Use `--single-process` in containers
860
+ - **Disable unnecessary features**: GPU, extensions, background networking
861
+ - **Memory**: Increase shared memory (`--shm-size=2g` in Docker)
862
+
863
+ ### FFmpeg Optimization
864
+
865
+ - **Streaming**: Use stdin instead of temp files
866
+ - **Encoder preset**: Use `fast` preset for balance
867
+ - **CRF**: Use 28 for good quality/size balance
868
+ - **Hardware acceleration**: Use h264_nvenc when available (NVIDIA GPU)
869
+
870
+ ### Frame Capture
871
+
872
+ - **Update frequency**: Don't report progress on every frame
873
+ - **Frame delay**: Small delay between frames for stability
874
+ - **JPEG quality**: 85% for good balance
875
+
876
+ ---
877
+
878
+ ## Testing Guidelines
879
+
880
+ ### Unit Tests
881
+
882
+ - **Renderer methods**: Test browser initialization, game loading (with mocks)
883
+ - **URL resolution**: Test game URL resolution logic
884
+ - **Error handling**: Test error scenarios
885
+
886
+ ### Integration Tests
887
+
888
+ - **End-to-end rendering**: Test full render flow with sample manifest
889
+ - **API endpoints**: Test HTTP endpoints with test client
890
+ - **Bundle serving**: Test bundle hosting and game loading
891
+
892
+ ### Test Structure
893
+
894
+ ```typescript
895
+ describe('PuppeteerRenderer', () => {
896
+ describe('render', () => {
897
+ it('should render video successfully', async () => {
898
+ // Test logic
899
+ });
900
+
901
+ it('should handle browser errors', async () => {
902
+ // Test error handling
903
+ });
904
+ });
905
+ });
906
+ ```
907
+
908
+ ---
909
+
910
+ ## Documentation
911
+
912
+ ### Code Comments
913
+
914
+ - **JSDoc**: Use JSDoc for public APIs
915
+ - **Complex logic**: Comment complex algorithms
916
+ - **Why, not what**: Comment why, not what (code should be self-documenting)
917
+
918
+ ```typescript
919
+ /**
920
+ * Resolve game URL from manifest bundleId or config
921
+ * Priority: config.gameUrl > manifest.bundleId > GAME_URL env
922
+ *
923
+ * @param manifest - Replay manifest
924
+ * @param configGameUrl - Optional explicit game URL
925
+ * @returns Resolved game URL
926
+ * @throws Error if bundle not found
927
+ */
928
+ function resolveGameUrl(manifest: ReplayManifestV1, configGameUrl?: string): string {
929
+ // Implementation
930
+ }
931
+ ```
932
+
933
+ ---
934
+
935
+ ## Resources
936
+
937
+ - **TypeScript Handbook**: https://www.typescriptlang.org/docs/
938
+ - **Express Guide**: https://expressjs.com/en/guide/routing.html
939
+ - **Puppeteer API**: https://pptr.dev/
940
+ - **FFmpeg Documentation**: https://ffmpeg.org/documentation.html
941
+ - **Node.js Streams**: https://nodejs.org/api/stream.html