@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.
@@ -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-104533/bundle-1769251533018.tar.gz"
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 "http://$SERVER_URL/api/bundles"
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
- // Handle optional timeline - games may not have timelines
115
- const timelineLength = Array.isArray(manifest.timeline) ? manifest.timeline.length : 1;
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
- // Handle optional timeline - games may not have timelines
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
- // Capture and stream frames
400
- for (let ageIndex = 0; ageIndex < totalAges; ageIndex++) {
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
- // Wait a bit for animation
407
- await new Promise(resolve => setTimeout(resolve, 100));
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
- // Capture frames for this age
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
- // Small delay between frames
452
- await new Promise(resolve => setTimeout(resolve, 1000 / config.fps / 2));
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