@agent-foundry/replay-server 1.0.0 → 1.0.1
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.
- package/.cursor/dev.mdc +941 -0
- package/.cursor/project.mdc +17 -2
- package/.env +30 -0
- package/Dockerfile +6 -0
- package/README.md +153 -12
- package/dist/cli/render.js +14 -4
- package/dist/cli/render.js.map +1 -1
- package/dist/renderer/PuppeteerRenderer.d.ts +12 -2
- package/dist/renderer/PuppeteerRenderer.d.ts.map +1 -1
- package/dist/renderer/PuppeteerRenderer.js +23 -16
- package/dist/renderer/PuppeteerRenderer.js.map +1 -1
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +200 -46
- package/dist/server/index.js.map +1 -1
- package/dist/services/BundleManager.d.ts +99 -0
- package/dist/services/BundleManager.d.ts.map +1 -0
- package/dist/services/BundleManager.js +410 -0
- package/dist/services/BundleManager.js.map +1 -0
- package/dist/services/OSSClient.d.ts +51 -0
- package/dist/services/OSSClient.d.ts.map +1 -0
- package/dist/services/OSSClient.js +207 -0
- package/dist/services/OSSClient.js.map +1 -0
- package/dist/services/index.d.ts +7 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +7 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/types.d.ts +73 -0
- package/dist/services/types.d.ts.map +1 -0
- package/dist/services/types.js +5 -0
- package/dist/services/types.js.map +1 -0
- package/docker-compose.local.yml +8 -0
- package/env.example +30 -0
- package/package.json +7 -3
- package/restart.sh +5 -0
- package/samples/jump_arena_0_ja-mks5um2x-nksbmz.json +1907 -0
- package/scripts/render-pipeline.sh +657 -0
- package/scripts/test-bundle-preload.sh +20 -0
- package/scripts/test-service-sts.sh +176 -0
- package/src/cli/render.ts +18 -7
- package/src/renderer/PuppeteerRenderer.ts +41 -21
- package/src/server/index.ts +249 -68
- package/src/services/BundleManager.ts +503 -0
- package/src/services/OSSClient.ts +286 -0
- package/src/services/index.ts +7 -0
- package/src/services/types.ts +78 -0
package/.cursor/dev.mdc
ADDED
|
@@ -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
|