@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,407 @@
1
+ /**
2
+ * Replay Render Server
3
+ *
4
+ * HTTP API for rendering replays to video
5
+ *
6
+ * Endpoints:
7
+ * - POST /render - Start a render job
8
+ * - GET /jobs - List all jobs
9
+ * - GET /status/:jobId - Get job status
10
+ * - GET /download/:jobId - Download completed video
11
+ * - GET /bundles - List available game bundles
12
+ */
13
+
14
+ import express, { type Express } from 'express';
15
+ import cors from 'cors';
16
+ import { v4 as uuidv4 } from 'uuid';
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { fileURLToPath } from 'url';
20
+ import { dirname } from 'path';
21
+ import { execSync } from 'child_process';
22
+ import { PuppeteerRenderer, RenderProgress } from '../renderer/PuppeteerRenderer.js';
23
+ import type { ReplayManifestV1 } from '@agent-foundry/replay';
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+
27
+ const app: Express = express();
28
+ app.use(cors());
29
+ app.use(express.json({ limit: '10mb' }));
30
+
31
+ // Configuration
32
+ const PORT = process.env.PORT || 3001;
33
+ const GAME_URL = process.env.GAME_URL || 'http://localhost:5173';
34
+ const OUTPUT_DIR = process.env.OUTPUT_DIR || path.join(process.cwd(), 'output');
35
+ const BUNDLES_DIR = process.env.BUNDLES_DIR || path.join(__dirname, '../../bundles');
36
+
37
+ /**
38
+ * GET /bundles
39
+ * List available game bundles
40
+ * This route must be registered BEFORE the static middleware to handle /bundles requests
41
+ */
42
+ app.get('/bundles', (req, res) => {
43
+ console.log('[GET /bundles] Request received');
44
+ try {
45
+ // Ensure bundles directory exists
46
+ if (!fs.existsSync(BUNDLES_DIR)) {
47
+ return res.json({ bundles: [], count: 0 });
48
+ }
49
+
50
+ // List directories in bundles folder
51
+ const entries = fs.readdirSync(BUNDLES_DIR, { withFileTypes: true });
52
+ const bundles = entries
53
+ .filter(entry => entry.isDirectory())
54
+ .map(entry => {
55
+ const bundlePath = path.join(BUNDLES_DIR, entry.name);
56
+ const indexPath = path.join(bundlePath, 'index.html');
57
+ const hasIndex = fs.existsSync(indexPath);
58
+
59
+ return {
60
+ bundleId: entry.name,
61
+ path: bundlePath,
62
+ url: `http://localhost:${PORT}/bundles/${entry.name}/`,
63
+ ready: hasIndex,
64
+ };
65
+ });
66
+
67
+ console.log('[GET /bundles] Found bundles:', bundles.length);
68
+ res.json({
69
+ bundles,
70
+ count: bundles.length,
71
+ bundlesDir: BUNDLES_DIR,
72
+ });
73
+ } catch (error) {
74
+ console.error('[GET /bundles] Error listing bundles:', error);
75
+ res.status(500).json({ error: 'Failed to list bundles' });
76
+ }
77
+ });
78
+
79
+ // Serve static game bundles (AFTER API route to allow /bundles/<bundleId>/ paths)
80
+ app.use('/bundles', express.static(BUNDLES_DIR, {
81
+ index: 'index.html',
82
+ extensions: ['html']
83
+ }));
84
+
85
+ /**
86
+ * GET /debug/chrome
87
+ * Debug endpoint to verify Chromium installation
88
+ */
89
+ app.get('/debug/chrome', async (req, res) => {
90
+ const results: any = {
91
+ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium',
92
+ tests: {}
93
+ };
94
+
95
+ try {
96
+ // Test 1: Check if chromium exists
97
+ const version = execSync(`${results.executablePath} --version`, {
98
+ encoding: 'utf8',
99
+ timeout: 5000
100
+ });
101
+ results.tests.version = { ok: true, output: version.trim() };
102
+ } catch (e: any) {
103
+ results.tests.version = {
104
+ ok: false,
105
+ error: e.message,
106
+ stderr: e.stderr?.toString()
107
+ };
108
+ }
109
+
110
+ try {
111
+ // Test 2: Check library dependencies
112
+ const ldd = execSync(`ldd ${results.executablePath}`, {
113
+ encoding: 'utf8',
114
+ timeout: 5000
115
+ });
116
+ results.tests.dependencies = { ok: true, summary: 'All libraries found' };
117
+ } catch (e: any) {
118
+ results.tests.dependencies = {
119
+ ok: false,
120
+ error: e.message,
121
+ stderr: e.stderr?.toString()
122
+ };
123
+ }
124
+
125
+ try {
126
+ // Test 3: Check if Chinese fonts are available
127
+ const fonts = execSync(`fc-list :lang=zh-cn`, {
128
+ encoding: 'utf8',
129
+ timeout: 5000
130
+ });
131
+ const fontLines = fonts.split('\n').filter(l => l.trim());
132
+ results.tests.chineseFonts = {
133
+ ok: fontLines.length > 0,
134
+ count: fontLines.length,
135
+ sample: fontLines.slice(0, 3)
136
+ };
137
+ } catch (e: any) {
138
+ results.tests.chineseFonts = {
139
+ ok: false,
140
+ error: 'fontconfig not available or no Chinese fonts found'
141
+ };
142
+ }
143
+
144
+ res.json(results);
145
+ });
146
+
147
+ /**
148
+ * Resolve game URL from manifest bundleId or config
149
+ */
150
+ function resolveGameUrl(manifest: ReplayManifestV1, configGameUrl?: string): string {
151
+ // 1. Explicit gameUrl in config takes precedence
152
+ if (configGameUrl) {
153
+ return configGameUrl;
154
+ }
155
+
156
+ // 2. Use bundleId from manifest to serve from local bundles
157
+ if (manifest.bundleId) {
158
+ const bundlePath = path.join(BUNDLES_DIR, manifest.bundleId);
159
+ if (!fs.existsSync(bundlePath)) {
160
+ throw new Error(`Bundle not found: ${manifest.bundleId}`);
161
+ }
162
+ return `http://localhost:${PORT}/bundles/${manifest.bundleId}/`;
163
+ }
164
+
165
+ // 3. Fall back to default GAME_URL
166
+ return GAME_URL;
167
+ }
168
+
169
+ // Job storage (in-memory for now)
170
+ interface RenderJob {
171
+ id: string;
172
+ status: 'pending' | 'processing' | 'completed' | 'failed';
173
+ progress: RenderProgress | null;
174
+ outputPath: string | null;
175
+ error: string | null;
176
+ createdAt: Date;
177
+ completedAt: Date | null;
178
+ }
179
+
180
+ const jobs = new Map<string, RenderJob>();
181
+
182
+ // Ensure output directory exists
183
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
184
+
185
+ /**
186
+ * POST /render
187
+ * Start a new render job
188
+ */
189
+ app.post('/render', async (req, res) => {
190
+ console.log('[POST /render] Request received');
191
+ try {
192
+ const { manifest, manifestUrl, config } = req.body;
193
+
194
+ // Get manifest from body or URL
195
+ let replayManifest: ReplayManifestV1;
196
+
197
+ if (manifest) {
198
+ replayManifest = manifest;
199
+ } else if (manifestUrl) {
200
+ const response = await fetch(manifestUrl);
201
+ if (!response.ok) {
202
+ console.error('[POST /render] Failed to fetch manifest from URL:', manifestUrl);
203
+ return res.status(400).json({ error: 'Failed to fetch manifest from URL' });
204
+ }
205
+ replayManifest = await response.json();
206
+ } else {
207
+ console.error('[POST /render] Missing required parameter: manifest or manifestUrl');
208
+ return res.status(400).json({ error: 'Either manifest or manifestUrl is required' });
209
+ }
210
+
211
+ // Validate manifest
212
+ if (replayManifest.schema !== 'lifeRestart.replay.v1') {
213
+ console.error('[POST /render] Invalid manifest schema:', replayManifest.schema);
214
+ return res.status(400).json({ error: 'Invalid manifest schema' });
215
+ }
216
+
217
+ // Create job
218
+ const jobId = uuidv4();
219
+ const outputPath = path.join(OUTPUT_DIR, `${jobId}.mp4`);
220
+
221
+ const job: RenderJob = {
222
+ id: jobId,
223
+ status: 'pending',
224
+ progress: null,
225
+ outputPath: null,
226
+ error: null,
227
+ createdAt: new Date(),
228
+ completedAt: null,
229
+ };
230
+
231
+ jobs.set(jobId, job);
232
+
233
+ // Resolve game URL from bundleId or config
234
+ let gameUrl: string;
235
+ try {
236
+ gameUrl = resolveGameUrl(replayManifest, config?.gameUrl);
237
+ } catch (error) {
238
+ const errorMessage = error instanceof Error ? error.message : String(error);
239
+ console.error('[POST /render] Failed to resolve game URL:', errorMessage);
240
+ return res.status(400).json({ error: errorMessage });
241
+ }
242
+
243
+ // Start rendering in background
244
+ processJob(job, replayManifest, {
245
+ gameUrl,
246
+ outputPath,
247
+ width: config?.width,
248
+ height: config?.height,
249
+ fps: config?.fps,
250
+ secondsPerAge: config?.secondsPerAge,
251
+ });
252
+
253
+ console.log('[POST /render] Render job started successfully, jobId:', jobId);
254
+ console.log('[POST /render] Using game URL:', gameUrl);
255
+ res.json({
256
+ jobId,
257
+ status: job.status,
258
+ message: 'Render job started',
259
+ });
260
+ } catch (error) {
261
+ console.error('[POST /render] Error starting render job:', error);
262
+ res.status(500).json({ error: 'Internal server error' });
263
+ }
264
+ });
265
+
266
+ /**
267
+ * GET /jobs
268
+ * List all jobs
269
+ */
270
+ app.get('/jobs', (req, res) => {
271
+ console.log('[GET /jobs] Request received');
272
+ try {
273
+ const jobList = Array.from(jobs.values()).map(job => ({
274
+ jobId: job.id,
275
+ status: job.status,
276
+ progress: job.progress,
277
+ error: job.error,
278
+ createdAt: job.createdAt,
279
+ completedAt: job.completedAt,
280
+ }));
281
+
282
+ console.log('[GET /jobs] Returning job list, count:', jobList.length);
283
+ res.json({
284
+ jobs: jobList,
285
+ count: jobList.length,
286
+ });
287
+ } catch (error) {
288
+ console.error('[GET /jobs] Error listing jobs:', error);
289
+ res.status(500).json({ error: 'Internal server error' });
290
+ }
291
+ });
292
+
293
+ /**
294
+ * GET /status/:jobId
295
+ * Get job status
296
+ */
297
+ app.get('/status/:jobId', (req, res) => {
298
+ const { jobId } = req.params;
299
+ const job = jobs.get(jobId);
300
+
301
+ if (!job) {
302
+ return res.status(404).json({ error: 'Job not found' });
303
+ }
304
+
305
+ res.json({
306
+ jobId: job.id,
307
+ status: job.status,
308
+ progress: job.progress,
309
+ error: job.error,
310
+ createdAt: job.createdAt,
311
+ completedAt: job.completedAt,
312
+ });
313
+ });
314
+
315
+ /**
316
+ * GET /download/:jobId
317
+ * Download completed video
318
+ */
319
+ app.get('/download/:jobId', (req, res) => {
320
+ const { jobId } = req.params;
321
+ const job = jobs.get(jobId);
322
+
323
+ if (!job) {
324
+ return res.status(404).json({ error: 'Job not found' });
325
+ }
326
+
327
+ if (job.status !== 'completed' || !job.outputPath) {
328
+ return res.status(400).json({ error: 'Job not completed' });
329
+ }
330
+
331
+ if (!fs.existsSync(job.outputPath)) {
332
+ return res.status(404).json({ error: 'Video file not found' });
333
+ }
334
+
335
+ res.download(job.outputPath, `replay-${jobId}.mp4`);
336
+ });
337
+
338
+ /**
339
+ * GET /health
340
+ * Health check endpoint
341
+ */
342
+ app.get('/health', (req, res) => {
343
+ // Count available bundles
344
+ let bundleCount = 0;
345
+ if (fs.existsSync(BUNDLES_DIR)) {
346
+ const entries = fs.readdirSync(BUNDLES_DIR, { withFileTypes: true });
347
+ bundleCount = entries.filter(e => e.isDirectory()).length;
348
+ }
349
+
350
+ res.json({
351
+ status: 'ok',
352
+ gameUrl: GAME_URL,
353
+ bundlesDir: BUNDLES_DIR,
354
+ bundleCount,
355
+ });
356
+ });
357
+
358
+ /**
359
+ * Process a render job
360
+ */
361
+ async function processJob(
362
+ job: RenderJob,
363
+ manifest: ReplayManifestV1,
364
+ config: {
365
+ gameUrl: string;
366
+ outputPath: string;
367
+ width?: number;
368
+ height?: number;
369
+ fps?: number;
370
+ secondsPerAge?: number;
371
+ }
372
+ ): Promise<void> {
373
+ job.status = 'processing';
374
+
375
+ const renderer = new PuppeteerRenderer();
376
+
377
+ try {
378
+ const result = await renderer.render(manifest, {
379
+ ...config,
380
+ onProgress: (progress) => {
381
+ job.progress = progress;
382
+ },
383
+ });
384
+
385
+ if (result.success) {
386
+ job.status = 'completed';
387
+ job.outputPath = result.outputPath || null;
388
+ } else {
389
+ job.status = 'failed';
390
+ job.error = result.error || 'Unknown error';
391
+ }
392
+ } catch (error) {
393
+ job.status = 'failed';
394
+ job.error = error instanceof Error ? error.message : String(error);
395
+ }
396
+
397
+ job.completedAt = new Date();
398
+ }
399
+
400
+ // Start server
401
+ app.listen(PORT, () => {
402
+ console.log(`🎬 Replay Render Server running on http://localhost:${PORT}`);
403
+ console.log(`📺 Default Game URL: ${GAME_URL}`);
404
+ console.log(`📦 Bundles directory: ${BUNDLES_DIR}`);
405
+ console.log(`📁 Output directory: ${OUTPUT_DIR}`);
406
+ });
407
+
package/tsconfig.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": [
7
+ "ES2022",
8
+ "DOM"
9
+ ],
10
+ "types": [
11
+ "node"
12
+ ],
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "outDir": "./dist",
17
+ "rootDir": "./src",
18
+ "strict": true,
19
+ "noImplicitAny": true,
20
+ "strictNullChecks": true,
21
+ "esModuleInterop": true,
22
+ "skipLibCheck": true,
23
+ "forceConsistentCasingInFileNames": true,
24
+ "resolveJsonModule": true,
25
+ "allowImportingTsExtensions": false,
26
+ "noEmit": false
27
+ },
28
+ "include": [
29
+ "src/**/*"
30
+ ],
31
+ "exclude": [
32
+ "node_modules",
33
+ "dist"
34
+ ]
35
+ }