@agent-foundry/replay-server 1.0.1 → 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.
- package/README.md +144 -15
- package/dist/renderer/PuppeteerRenderer.d.ts +16 -0
- package/dist/renderer/PuppeteerRenderer.d.ts.map +1 -1
- package/dist/renderer/PuppeteerRenderer.js +115 -24
- package/dist/renderer/PuppeteerRenderer.js.map +1 -1
- package/docker-compose.local.yml +2 -0
- package/package.json +1 -1
- package/samples/{jump_arena_0_ja-mks5um2x-nksbmz.json → jump_arena_5_ja-mksi5fku-qgk5iq.json} +934 -889
- package/scripts/test-bundle-preload.sh +2 -2
- package/src/renderer/PuppeteerRenderer.ts +156 -23
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
SERVER_URL="http://localhost:3001"
|
|
4
4
|
BUNDLE_ID="game-jump-arena"
|
|
5
|
-
BUNDLE_URL="https://agent-foundry.oss-cn-beijing.aliyuncs.com/studio-bundles/92922750-9ea6-4fc7-aeef-a13b8cc9eae5/game-jump-arena/2026.01.24-
|
|
5
|
+
BUNDLE_URL="https://agent-foundry.oss-cn-beijing.aliyuncs.com/studio-bundles/92922750-9ea6-4fc7-aeef-a13b8cc9eae5/game-jump-arena/2026.01.24-160727/bundle-1769270846565.tar.gz"
|
|
6
6
|
|
|
7
7
|
set -x
|
|
8
8
|
|
|
@@ -17,4 +17,4 @@ curl -s -X POST "$SERVER_URL/api/bundles/preload" \
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
sleep 5
|
|
20
|
-
curl -s "
|
|
20
|
+
curl -s "$SERVER_URL/api/bundles"
|
|
@@ -21,10 +21,30 @@ export interface GenericReplayManifest {
|
|
|
21
21
|
schema: string;
|
|
22
22
|
bundleId?: string;
|
|
23
23
|
gameId: string;
|
|
24
|
-
timeline?: unknown[]; // Optional - games may not have timelines
|
|
24
|
+
timeline?: unknown[]; // Optional - games may not have timelines (LifeRestart uses this)
|
|
25
|
+
summary?: {
|
|
26
|
+
durationSeconds?: number; // JumpArena provides duration directly
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
};
|
|
25
29
|
[key: string]: unknown;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Replay mode determines how frames are captured:
|
|
34
|
+
* - 'age-based': Discrete ages with replay-next-age events (LifeRestart)
|
|
35
|
+
* - 'continuous': Real-time simulation without events (JumpArena)
|
|
36
|
+
*/
|
|
37
|
+
type ReplayMode = 'age-based' | 'continuous';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Replay timing configuration calculated from manifest
|
|
41
|
+
*/
|
|
42
|
+
interface ReplayTiming {
|
|
43
|
+
mode: ReplayMode;
|
|
44
|
+
durationSeconds: number;
|
|
45
|
+
totalFrames: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
export interface RenderConfig {
|
|
29
49
|
/** URL of the game (can be local file:// or http://) */
|
|
30
50
|
gameUrl: string;
|
|
@@ -84,6 +104,55 @@ export class PuppeteerRenderer {
|
|
|
84
104
|
private pageErrors: string[] = [];
|
|
85
105
|
private failedRequests: Array<{ url: string; errorText?: string }> = [];
|
|
86
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Calculate replay timing based on manifest fields
|
|
109
|
+
*
|
|
110
|
+
* Detection priority (field-based, not schema-name based):
|
|
111
|
+
* 1. If manifest.summary.durationSeconds exists → continuous mode
|
|
112
|
+
* 2. If manifest.timeline[] exists → age-based mode
|
|
113
|
+
* 3. Fallback to 10s continuous mode
|
|
114
|
+
*
|
|
115
|
+
* Continuous mode: Real-time simulation, frames captured at regular intervals
|
|
116
|
+
* Age-based mode: Discrete ages with replay-next-age events
|
|
117
|
+
*/
|
|
118
|
+
private calculateReplayTiming(
|
|
119
|
+
manifest: GenericReplayManifest,
|
|
120
|
+
config: { fps: number; secondsPerAge: number }
|
|
121
|
+
): ReplayTiming {
|
|
122
|
+
// Priority 1: Check for summary.durationSeconds (continuous mode)
|
|
123
|
+
const durationSeconds = manifest.summary?.durationSeconds;
|
|
124
|
+
if (typeof durationSeconds === 'number' && durationSeconds > 0) {
|
|
125
|
+
const totalFrames = Math.ceil(durationSeconds * config.fps);
|
|
126
|
+
console.log(`📼 Continuous mode (durationSeconds): ${durationSeconds.toFixed(2)}s, ${totalFrames} frames`);
|
|
127
|
+
return {
|
|
128
|
+
mode: 'continuous',
|
|
129
|
+
durationSeconds,
|
|
130
|
+
totalFrames,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Priority 2: Check for timeline[] (age-based mode)
|
|
135
|
+
if (Array.isArray(manifest.timeline) && manifest.timeline.length > 0) {
|
|
136
|
+
const timelineLength = manifest.timeline.length;
|
|
137
|
+
const duration = timelineLength * config.secondsPerAge;
|
|
138
|
+
const totalFrames = Math.ceil(duration * config.fps);
|
|
139
|
+
console.log(`📼 Age-based mode (timeline): ${timelineLength} ages, ${duration.toFixed(2)}s, ${totalFrames} frames`);
|
|
140
|
+
return {
|
|
141
|
+
mode: 'age-based',
|
|
142
|
+
durationSeconds: duration,
|
|
143
|
+
totalFrames,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Fallback: No duration info, use default continuous mode
|
|
148
|
+
console.warn('📼 No duration info in manifest, using default 10s continuous mode');
|
|
149
|
+
return {
|
|
150
|
+
mode: 'continuous',
|
|
151
|
+
durationSeconds: 10,
|
|
152
|
+
totalFrames: Math.ceil(10 * config.fps),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
87
156
|
/**
|
|
88
157
|
* Render a replay manifest to video
|
|
89
158
|
*/
|
|
@@ -110,12 +179,15 @@ export class PuppeteerRenderer {
|
|
|
110
179
|
this.reportProgress(config.onProgress, 'init', 0, 1, 'Loading game...');
|
|
111
180
|
await this.loadGame(fullConfig.gameUrl, manifest);
|
|
112
181
|
|
|
182
|
+
// Calculate timing based on manifest schema
|
|
183
|
+
const timing = this.calculateReplayTiming(manifest, {
|
|
184
|
+
fps: fullConfig.fps,
|
|
185
|
+
secondsPerAge: fullConfig.secondsPerAge,
|
|
186
|
+
});
|
|
187
|
+
|
|
113
188
|
// Capture and encode frames in streaming mode
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const frameCount = timelineLength * fullConfig.secondsPerAge * fullConfig.fps;
|
|
117
|
-
this.reportProgress(config.onProgress, 'capture', 0, frameCount, 'Starting capture & encode...');
|
|
118
|
-
await this.captureAndEncodeFrames(manifest, fullConfig, config.onProgress);
|
|
189
|
+
this.reportProgress(config.onProgress, 'capture', 0, timing.totalFrames, 'Starting capture & encode...');
|
|
190
|
+
await this.captureAndEncodeFrames(manifest, fullConfig, timing, config.onProgress);
|
|
119
191
|
|
|
120
192
|
const duration = (Date.now() - startTime) / 1000;
|
|
121
193
|
this.reportProgress(config.onProgress, 'complete', 1, 1, `Completed in ${duration.toFixed(1)}s`);
|
|
@@ -295,13 +367,14 @@ export class PuppeteerRenderer {
|
|
|
295
367
|
private async captureAndEncodeFrames(
|
|
296
368
|
manifest: GenericReplayManifest,
|
|
297
369
|
config: RenderConfig & typeof DEFAULT_CONFIG,
|
|
370
|
+
timing: ReplayTiming,
|
|
298
371
|
onProgress?: (progress: RenderProgress) => void
|
|
299
372
|
): Promise<void> {
|
|
300
373
|
// Use encoder based on configuration
|
|
301
374
|
const encoder = config.useHardwareAcceleration ? 'h264_nvenc' : 'libx264';
|
|
302
375
|
console.log(`📼 Using encoder: ${encoder} (Hardware acceleration: ${config.useHardwareAcceleration ? 'enabled' : 'disabled'})`);
|
|
303
376
|
|
|
304
|
-
await this.captureAndEncodeFramesWithEncoder(manifest, config, encoder, onProgress);
|
|
377
|
+
await this.captureAndEncodeFramesWithEncoder(manifest, config, timing, encoder, onProgress);
|
|
305
378
|
}
|
|
306
379
|
|
|
307
380
|
/**
|
|
@@ -310,17 +383,20 @@ export class PuppeteerRenderer {
|
|
|
310
383
|
private async captureAndEncodeFramesWithEncoder(
|
|
311
384
|
manifest: GenericReplayManifest,
|
|
312
385
|
config: RenderConfig & typeof DEFAULT_CONFIG,
|
|
386
|
+
timing: ReplayTiming,
|
|
313
387
|
encoder: 'h264_nvenc' | 'libx264',
|
|
314
388
|
onProgress?: (progress: RenderProgress) => void
|
|
315
389
|
): Promise<void> {
|
|
316
390
|
if (!this.page) throw new Error('Browser not initialized');
|
|
317
391
|
|
|
318
|
-
|
|
392
|
+
const { mode, totalFrames, durationSeconds } = timing;
|
|
393
|
+
|
|
394
|
+
// For age-based mode, calculate ages and frames per age
|
|
319
395
|
const timelineLength = Array.isArray(manifest.timeline) ? manifest.timeline.length : 1;
|
|
320
396
|
const totalAges = timelineLength;
|
|
321
|
-
const framesPerAge = config.secondsPerAge * config.fps;
|
|
322
|
-
const totalFrames = totalAges * framesPerAge;
|
|
397
|
+
const framesPerAge = Math.ceil(config.secondsPerAge * config.fps);
|
|
323
398
|
|
|
399
|
+
console.log(`📼 Replay mode: ${mode}, duration: ${durationSeconds.toFixed(2)}s, frames: ${totalFrames}`);
|
|
324
400
|
console.log(`📼 Using encoder: ${encoder}`);
|
|
325
401
|
|
|
326
402
|
// Launch FFmpeg process with stdin input
|
|
@@ -396,18 +472,15 @@ export class PuppeteerRenderer {
|
|
|
396
472
|
}
|
|
397
473
|
|
|
398
474
|
try {
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
// Advance to next age in the game
|
|
402
|
-
await this.page.evaluate(() => {
|
|
403
|
-
window.dispatchEvent(new CustomEvent('replay-next-age'));
|
|
404
|
-
});
|
|
475
|
+
// Frame interval in ms (for continuous mode timing)
|
|
476
|
+
const frameIntervalMs = 1000 / config.fps;
|
|
405
477
|
|
|
406
|
-
|
|
407
|
-
|
|
478
|
+
if (mode === 'continuous') {
|
|
479
|
+
// CONTINUOUS MODE: Capture frames at regular intervals
|
|
480
|
+
// Game runs in real-time, we just capture screenshots
|
|
481
|
+
console.log(`📼 Continuous mode: capturing ${totalFrames} frames over ${durationSeconds.toFixed(2)}s`);
|
|
408
482
|
|
|
409
|
-
|
|
410
|
-
for (let f = 0; f < framesPerAge; f++) {
|
|
483
|
+
for (let f = 0; f < totalFrames; f++) {
|
|
411
484
|
// Check if FFmpeg process has closed unexpectedly
|
|
412
485
|
if (ffmpegClosed) {
|
|
413
486
|
throw new Error('FFmpeg process closed unexpectedly');
|
|
@@ -437,7 +510,7 @@ export class PuppeteerRenderer {
|
|
|
437
510
|
|
|
438
511
|
frameIndex++;
|
|
439
512
|
|
|
440
|
-
// Update progress
|
|
513
|
+
// Update progress every second
|
|
441
514
|
if (frameIndex % config.fps === 0) {
|
|
442
515
|
this.reportProgress(
|
|
443
516
|
onProgress,
|
|
@@ -448,8 +521,68 @@ export class PuppeteerRenderer {
|
|
|
448
521
|
);
|
|
449
522
|
}
|
|
450
523
|
|
|
451
|
-
//
|
|
452
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
524
|
+
// Wait for next frame interval (allow game to advance simulation)
|
|
525
|
+
await new Promise(resolve => setTimeout(resolve, frameIntervalMs));
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
// AGE-BASED MODE: Dispatch replay-next-age for each age
|
|
529
|
+
// Used by LifeRestart and similar games
|
|
530
|
+
console.log(`📼 Age-based mode: ${totalAges} ages, ${framesPerAge} frames per age`);
|
|
531
|
+
|
|
532
|
+
for (let ageIndex = 0; ageIndex < totalAges; ageIndex++) {
|
|
533
|
+
// Advance to next age in the game
|
|
534
|
+
await this.page.evaluate(() => {
|
|
535
|
+
window.dispatchEvent(new CustomEvent('replay-next-age'));
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Wait a bit for animation
|
|
539
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
540
|
+
|
|
541
|
+
// Capture frames for this age
|
|
542
|
+
for (let f = 0; f < framesPerAge; f++) {
|
|
543
|
+
// Check if FFmpeg process has closed unexpectedly
|
|
544
|
+
if (ffmpegClosed) {
|
|
545
|
+
throw new Error('FFmpeg process closed unexpectedly');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Capture screenshot as JPEG buffer
|
|
549
|
+
const screenshotBuffer = await this.page.screenshot({
|
|
550
|
+
type: 'jpeg',
|
|
551
|
+
quality: 85,
|
|
552
|
+
encoding: 'binary',
|
|
553
|
+
}) as Buffer;
|
|
554
|
+
|
|
555
|
+
// Write frame to FFmpeg stdin
|
|
556
|
+
if (ffmpeg.stdin && !ffmpeg.stdin.destroyed) {
|
|
557
|
+
const writeSuccess = ffmpeg.stdin.write(screenshotBuffer);
|
|
558
|
+
if (!writeSuccess) {
|
|
559
|
+
// Wait for drain event if buffer is full
|
|
560
|
+
await new Promise<void>((resolve) => {
|
|
561
|
+
if (ffmpeg.stdin) {
|
|
562
|
+
ffmpeg.stdin.once('drain', resolve);
|
|
563
|
+
} else {
|
|
564
|
+
resolve();
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
frameIndex++;
|
|
571
|
+
|
|
572
|
+
// Update progress
|
|
573
|
+
if (frameIndex % config.fps === 0) {
|
|
574
|
+
this.reportProgress(
|
|
575
|
+
onProgress,
|
|
576
|
+
'capture',
|
|
577
|
+
frameIndex,
|
|
578
|
+
totalFrames,
|
|
579
|
+
`Capturing & encoding frame ${frameIndex}/${totalFrames} (${encoder})`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Small delay between frames
|
|
584
|
+
await new Promise(resolve => setTimeout(resolve, frameIntervalMs / 2));
|
|
585
|
+
}
|
|
453
586
|
}
|
|
454
587
|
}
|
|
455
588
|
|