@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.
- package/.cursor/dev.mdc +941 -0
- package/.cursor/project.mdc +17 -2
- package/.env +30 -0
- package/Dockerfile +6 -0
- package/README.md +297 -27
- package/dist/cli/render.js +14 -4
- package/dist/cli/render.js.map +1 -1
- package/dist/renderer/PuppeteerRenderer.d.ts +28 -2
- package/dist/renderer/PuppeteerRenderer.d.ts.map +1 -1
- package/dist/renderer/PuppeteerRenderer.js +134 -36
- 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 +10 -0
- package/env.example +30 -0
- package/package.json +7 -3
- package/restart.sh +5 -0
- package/samples/jump_arena_5_ja-mksi5fku-qgk5iq.json +1952 -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 +192 -39
- 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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Test script for BFF /studio/service/sts endpoint
|
|
4
|
+
#
|
|
5
|
+
# This script tests the service-to-service authentication for obtaining
|
|
6
|
+
# STS credentials from the BFF service.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# ./scripts/test-service-sts.sh
|
|
10
|
+
#
|
|
11
|
+
# Environment variables:
|
|
12
|
+
# BFF_BASE_URL - BFF service URL (default: http://localhost:11001)
|
|
13
|
+
# BFF_SERVICE_TOKEN - Set to SUPABASE_JWT_SECRET from BFF's .env
|
|
14
|
+
#
|
|
15
|
+
# The script will:
|
|
16
|
+
# 1. Call POST /studio/service/sts with the service token
|
|
17
|
+
# 2. Display the STS credentials (masked for security)
|
|
18
|
+
# 3. Verify the response structure
|
|
19
|
+
#
|
|
20
|
+
|
|
21
|
+
set -e
|
|
22
|
+
|
|
23
|
+
# Configuration
|
|
24
|
+
BFF_BASE_URL="${BFF_BASE_URL:-http://localhost:11001}"
|
|
25
|
+
BFF_SERVICE_TOKEN="${BFF_SERVICE_TOKEN:-}"
|
|
26
|
+
|
|
27
|
+
echo "=============================================="
|
|
28
|
+
echo "Testing BFF /studio/service/sts endpoint"
|
|
29
|
+
echo "=============================================="
|
|
30
|
+
echo ""
|
|
31
|
+
echo "BFF URL: $BFF_BASE_URL"
|
|
32
|
+
echo "Service Token: ${BFF_SERVICE_TOKEN:0:20}..."
|
|
33
|
+
echo ""
|
|
34
|
+
|
|
35
|
+
# Check if service token is configured
|
|
36
|
+
if [ -z "$BFF_SERVICE_TOKEN" ]; then
|
|
37
|
+
echo "ERROR: BFF_SERVICE_TOKEN is not set"
|
|
38
|
+
echo ""
|
|
39
|
+
echo "Please set BFF_SERVICE_TOKEN to the value of SUPABASE_JWT_SECRET from BFF's .env file:"
|
|
40
|
+
echo ""
|
|
41
|
+
echo " export BFF_SERVICE_TOKEN=your-supabase-jwt-secret"
|
|
42
|
+
echo ""
|
|
43
|
+
echo "Or add it to your .env file:"
|
|
44
|
+
echo ""
|
|
45
|
+
echo " BFF_SERVICE_TOKEN=your-supabase-jwt-secret"
|
|
46
|
+
echo ""
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Test the endpoint
|
|
51
|
+
echo "Calling POST $BFF_BASE_URL/studio/service/sts..."
|
|
52
|
+
echo ""
|
|
53
|
+
|
|
54
|
+
response=$(curl -s -w "\n%{http_code}" -X POST "$BFF_BASE_URL/studio/service/sts" \
|
|
55
|
+
-H "Content-Type: application/json" \
|
|
56
|
+
-H "Authorization: Bearer $BFF_SERVICE_TOKEN")
|
|
57
|
+
|
|
58
|
+
# Extract HTTP status code (last line)
|
|
59
|
+
http_code=$(echo "$response" | tail -n 1)
|
|
60
|
+
# Extract body (all but last line)
|
|
61
|
+
body=$(echo "$response" | sed '$d')
|
|
62
|
+
|
|
63
|
+
echo "HTTP Status: $http_code"
|
|
64
|
+
echo ""
|
|
65
|
+
|
|
66
|
+
if [ "$http_code" -eq 200 ]; then
|
|
67
|
+
echo "SUCCESS! STS credentials received."
|
|
68
|
+
echo ""
|
|
69
|
+
|
|
70
|
+
# Parse and display response (with masked secrets)
|
|
71
|
+
if command -v jq &> /dev/null; then
|
|
72
|
+
echo "Response:"
|
|
73
|
+
echo "$body" | jq '{
|
|
74
|
+
bucket: .bucket,
|
|
75
|
+
region: .region,
|
|
76
|
+
credentials: {
|
|
77
|
+
accessKeyId: (.credentials.accessKeyId | .[0:10] + "..."),
|
|
78
|
+
accessKeySecret: "***MASKED***",
|
|
79
|
+
securityToken: (.credentials.securityToken | .[0:20] + "..."),
|
|
80
|
+
expiration: .credentials.expiration
|
|
81
|
+
}
|
|
82
|
+
}'
|
|
83
|
+
|
|
84
|
+
# Validate response structure
|
|
85
|
+
echo ""
|
|
86
|
+
echo "Validation:"
|
|
87
|
+
|
|
88
|
+
has_bucket=$(echo "$body" | jq -r '.bucket // empty')
|
|
89
|
+
has_region=$(echo "$body" | jq -r '.region // empty')
|
|
90
|
+
has_access_key=$(echo "$body" | jq -r '.credentials.accessKeyId // empty')
|
|
91
|
+
has_secret=$(echo "$body" | jq -r '.credentials.accessKeySecret // empty')
|
|
92
|
+
has_token=$(echo "$body" | jq -r '.credentials.securityToken // empty')
|
|
93
|
+
has_expiration=$(echo "$body" | jq -r '.credentials.expiration // empty')
|
|
94
|
+
|
|
95
|
+
all_valid=true
|
|
96
|
+
|
|
97
|
+
if [ -n "$has_bucket" ]; then
|
|
98
|
+
echo " [OK] bucket: $has_bucket"
|
|
99
|
+
else
|
|
100
|
+
echo " [FAIL] bucket: missing"
|
|
101
|
+
all_valid=false
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
if [ -n "$has_region" ]; then
|
|
105
|
+
echo " [OK] region: $has_region"
|
|
106
|
+
else
|
|
107
|
+
echo " [FAIL] region: missing"
|
|
108
|
+
all_valid=false
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
if [ -n "$has_access_key" ]; then
|
|
112
|
+
echo " [OK] credentials.accessKeyId: present"
|
|
113
|
+
else
|
|
114
|
+
echo " [FAIL] credentials.accessKeyId: missing"
|
|
115
|
+
all_valid=false
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
if [ -n "$has_secret" ]; then
|
|
119
|
+
echo " [OK] credentials.accessKeySecret: present"
|
|
120
|
+
else
|
|
121
|
+
echo " [FAIL] credentials.accessKeySecret: missing"
|
|
122
|
+
all_valid=false
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
if [ -n "$has_token" ]; then
|
|
126
|
+
echo " [OK] credentials.securityToken: present"
|
|
127
|
+
else
|
|
128
|
+
echo " [FAIL] credentials.securityToken: missing"
|
|
129
|
+
all_valid=false
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
if [ -n "$has_expiration" ]; then
|
|
133
|
+
echo " [OK] credentials.expiration: $has_expiration"
|
|
134
|
+
else
|
|
135
|
+
echo " [FAIL] credentials.expiration: missing"
|
|
136
|
+
all_valid=false
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
echo ""
|
|
140
|
+
if [ "$all_valid" = true ]; then
|
|
141
|
+
echo "All validations passed!"
|
|
142
|
+
exit 0
|
|
143
|
+
else
|
|
144
|
+
echo "Some validations failed!"
|
|
145
|
+
exit 1
|
|
146
|
+
fi
|
|
147
|
+
else
|
|
148
|
+
echo "Response (raw):"
|
|
149
|
+
echo "$body"
|
|
150
|
+
echo ""
|
|
151
|
+
echo "Note: Install 'jq' for prettier output"
|
|
152
|
+
fi
|
|
153
|
+
else
|
|
154
|
+
echo "FAILED! Error response:"
|
|
155
|
+
echo ""
|
|
156
|
+
if command -v jq &> /dev/null; then
|
|
157
|
+
echo "$body" | jq .
|
|
158
|
+
else
|
|
159
|
+
echo "$body"
|
|
160
|
+
fi
|
|
161
|
+
echo ""
|
|
162
|
+
|
|
163
|
+
case $http_code in
|
|
164
|
+
401)
|
|
165
|
+
echo "Authentication failed. Check that BFF_SERVICE_TOKEN matches SUPABASE_JWT_SECRET in BFF."
|
|
166
|
+
;;
|
|
167
|
+
500)
|
|
168
|
+
echo "Server error. Check BFF logs for details."
|
|
169
|
+
;;
|
|
170
|
+
*)
|
|
171
|
+
echo "Unexpected error."
|
|
172
|
+
;;
|
|
173
|
+
esac
|
|
174
|
+
|
|
175
|
+
exit 1
|
|
176
|
+
fi
|
package/src/cli/render.ts
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
|
-
import { PuppeteerRenderer, RenderProgress } from '../renderer/PuppeteerRenderer.js';
|
|
13
|
-
import type { ReplayManifestV1 } from '@agent-foundry/replay';
|
|
12
|
+
import { PuppeteerRenderer, RenderProgress, GenericReplayManifest } from '../renderer/PuppeteerRenderer.js';
|
|
14
13
|
|
|
15
14
|
interface CliArgs {
|
|
16
15
|
manifest?: string;
|
|
@@ -130,7 +129,7 @@ Examples:
|
|
|
130
129
|
`);
|
|
131
130
|
}
|
|
132
131
|
|
|
133
|
-
async function loadManifest(args: CliArgs): Promise<
|
|
132
|
+
async function loadManifest(args: CliArgs): Promise<GenericReplayManifest> {
|
|
134
133
|
if (args.manifest) {
|
|
135
134
|
const manifestPath = path.resolve(args.manifest);
|
|
136
135
|
if (!fs.existsSync(manifestPath)) {
|
|
@@ -183,12 +182,24 @@ async function main(): Promise<void> {
|
|
|
183
182
|
console.log('📄 Loading manifest...');
|
|
184
183
|
const manifest = await loadManifest(args);
|
|
185
184
|
console.log(` Game ID: ${manifest.gameId}`);
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
|
|
186
|
+
// Handle optional timeline
|
|
187
|
+
const timelineLength = Array.isArray(manifest.timeline) ? manifest.timeline.length : 0;
|
|
188
|
+
if (timelineLength > 0) {
|
|
189
|
+
console.log(` Timeline: ${timelineLength} ages`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Handle optional highlights
|
|
193
|
+
const highlightsLength = Array.isArray((manifest as any).highlights) ? (manifest as any).highlights.length : 0;
|
|
194
|
+
if (highlightsLength > 0) {
|
|
195
|
+
console.log(` Highlights: ${highlightsLength}`);
|
|
196
|
+
}
|
|
188
197
|
|
|
189
198
|
// Estimate duration
|
|
190
|
-
const estimatedDuration =
|
|
191
|
-
|
|
199
|
+
const estimatedDuration = timelineLength * args.secondsPerAge;
|
|
200
|
+
if (estimatedDuration > 0) {
|
|
201
|
+
console.log(` Estimated video duration: ${estimatedDuration}s`);
|
|
202
|
+
}
|
|
192
203
|
console.log('');
|
|
193
204
|
|
|
194
205
|
// Create output directory
|
|
@@ -12,7 +12,38 @@
|
|
|
12
12
|
import puppeteer, { Browser, Page } from 'puppeteer';
|
|
13
13
|
import { spawn, ChildProcess } from 'child_process';
|
|
14
14
|
import * as path from 'path';
|
|
15
|
-
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generic replay manifest interface
|
|
18
|
+
* Supports any game type - games interpret their own schemas
|
|
19
|
+
*/
|
|
20
|
+
export interface GenericReplayManifest {
|
|
21
|
+
schema: string;
|
|
22
|
+
bundleId?: string;
|
|
23
|
+
gameId: string;
|
|
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
|
+
};
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
}
|
|
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
|
+
}
|
|
16
47
|
|
|
17
48
|
export interface RenderConfig {
|
|
18
49
|
/** URL of the game (can be local file:// or http://) */
|
|
@@ -73,10 +104,59 @@ export class PuppeteerRenderer {
|
|
|
73
104
|
private pageErrors: string[] = [];
|
|
74
105
|
private failedRequests: Array<{ url: string; errorText?: string }> = [];
|
|
75
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
|
+
|
|
76
156
|
/**
|
|
77
157
|
* Render a replay manifest to video
|
|
78
158
|
*/
|
|
79
|
-
async render(manifest:
|
|
159
|
+
async render(manifest: GenericReplayManifest, config: RenderConfig): Promise<RenderResult> {
|
|
80
160
|
const startTime = Date.now();
|
|
81
161
|
const fullConfig: RenderConfig & typeof DEFAULT_CONFIG = {
|
|
82
162
|
...DEFAULT_CONFIG,
|
|
@@ -99,10 +179,15 @@ export class PuppeteerRenderer {
|
|
|
99
179
|
this.reportProgress(config.onProgress, 'init', 0, 1, 'Loading game...');
|
|
100
180
|
await this.loadGame(fullConfig.gameUrl, manifest);
|
|
101
181
|
|
|
182
|
+
// Calculate timing based on manifest schema
|
|
183
|
+
const timing = this.calculateReplayTiming(manifest, {
|
|
184
|
+
fps: fullConfig.fps,
|
|
185
|
+
secondsPerAge: fullConfig.secondsPerAge,
|
|
186
|
+
});
|
|
187
|
+
|
|
102
188
|
// Capture and encode frames in streaming mode
|
|
103
|
-
|
|
104
|
-
this.
|
|
105
|
-
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);
|
|
106
191
|
|
|
107
192
|
const duration = (Date.now() - startTime) / 1000;
|
|
108
193
|
this.reportProgress(config.onProgress, 'complete', 1, 1, `Completed in ${duration.toFixed(1)}s`);
|
|
@@ -178,7 +263,7 @@ export class PuppeteerRenderer {
|
|
|
178
263
|
});
|
|
179
264
|
}
|
|
180
265
|
|
|
181
|
-
private async loadGame(gameUrl: string, manifest:
|
|
266
|
+
private async loadGame(gameUrl: string, manifest: GenericReplayManifest): Promise<void> {
|
|
182
267
|
if (!this.page) throw new Error('Browser not initialized');
|
|
183
268
|
|
|
184
269
|
// Navigate to game with replay mode query parameter
|
|
@@ -194,23 +279,28 @@ export class PuppeteerRenderer {
|
|
|
194
279
|
|
|
195
280
|
console.log('📼 Page loaded, waiting for React to initialize...');
|
|
196
281
|
|
|
197
|
-
// Wait for React to initialize
|
|
282
|
+
// Wait for React to initialize
|
|
283
|
+
// Try to detect common app containers, but don't fail if not found
|
|
198
284
|
try {
|
|
199
285
|
await this.page.waitForFunction(() => {
|
|
200
|
-
//
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
286
|
+
// Check for common app container patterns
|
|
287
|
+
const ionApp = document.querySelector('ion-app');
|
|
288
|
+
const rootDiv = document.querySelector('#root');
|
|
289
|
+
const appDiv = document.querySelector('[id*="app"], [class*="app"]');
|
|
290
|
+
|
|
291
|
+
// If using Ionic, wait for loading to complete
|
|
292
|
+
if (ionApp) {
|
|
293
|
+
const loading = document.querySelector('ion-spinner');
|
|
294
|
+
return loading === null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Otherwise, just check if root is mounted
|
|
298
|
+
return rootDiv !== null || appDiv !== null;
|
|
299
|
+
}, { timeout: 10000 });
|
|
300
|
+
console.log('📼 App container detected');
|
|
205
301
|
} catch (error) {
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
console.error('📼 Page errors detected:', this.pageErrors);
|
|
209
|
-
}
|
|
210
|
-
if (this.failedRequests.length > 0) {
|
|
211
|
-
console.error('📼 Failed requests:', this.failedRequests);
|
|
212
|
-
}
|
|
213
|
-
throw error;
|
|
302
|
+
// Non-fatal - continue anyway
|
|
303
|
+
console.log('📼 App container detection timed out, continuing anyway...');
|
|
214
304
|
}
|
|
215
305
|
|
|
216
306
|
// Log any errors that occurred (but didn't prevent initialization)
|
|
@@ -275,32 +365,38 @@ export class PuppeteerRenderer {
|
|
|
275
365
|
* Frames are captured as JPEG buffers and streamed directly to FFmpeg
|
|
276
366
|
*/
|
|
277
367
|
private async captureAndEncodeFrames(
|
|
278
|
-
manifest:
|
|
368
|
+
manifest: GenericReplayManifest,
|
|
279
369
|
config: RenderConfig & typeof DEFAULT_CONFIG,
|
|
370
|
+
timing: ReplayTiming,
|
|
280
371
|
onProgress?: (progress: RenderProgress) => void
|
|
281
372
|
): Promise<void> {
|
|
282
373
|
// Use encoder based on configuration
|
|
283
374
|
const encoder = config.useHardwareAcceleration ? 'h264_nvenc' : 'libx264';
|
|
284
375
|
console.log(`📼 Using encoder: ${encoder} (Hardware acceleration: ${config.useHardwareAcceleration ? 'enabled' : 'disabled'})`);
|
|
285
376
|
|
|
286
|
-
await this.captureAndEncodeFramesWithEncoder(manifest, config, encoder, onProgress);
|
|
377
|
+
await this.captureAndEncodeFramesWithEncoder(manifest, config, timing, encoder, onProgress);
|
|
287
378
|
}
|
|
288
379
|
|
|
289
380
|
/**
|
|
290
381
|
* Internal method to capture and encode with a specific encoder
|
|
291
382
|
*/
|
|
292
383
|
private async captureAndEncodeFramesWithEncoder(
|
|
293
|
-
manifest:
|
|
384
|
+
manifest: GenericReplayManifest,
|
|
294
385
|
config: RenderConfig & typeof DEFAULT_CONFIG,
|
|
386
|
+
timing: ReplayTiming,
|
|
295
387
|
encoder: 'h264_nvenc' | 'libx264',
|
|
296
388
|
onProgress?: (progress: RenderProgress) => void
|
|
297
389
|
): Promise<void> {
|
|
298
390
|
if (!this.page) throw new Error('Browser not initialized');
|
|
299
391
|
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
392
|
+
const { mode, totalFrames, durationSeconds } = timing;
|
|
393
|
+
|
|
394
|
+
// For age-based mode, calculate ages and frames per age
|
|
395
|
+
const timelineLength = Array.isArray(manifest.timeline) ? manifest.timeline.length : 1;
|
|
396
|
+
const totalAges = timelineLength;
|
|
397
|
+
const framesPerAge = Math.ceil(config.secondsPerAge * config.fps);
|
|
303
398
|
|
|
399
|
+
console.log(`📼 Replay mode: ${mode}, duration: ${durationSeconds.toFixed(2)}s, frames: ${totalFrames}`);
|
|
304
400
|
console.log(`📼 Using encoder: ${encoder}`);
|
|
305
401
|
|
|
306
402
|
// Launch FFmpeg process with stdin input
|
|
@@ -376,18 +472,15 @@ export class PuppeteerRenderer {
|
|
|
376
472
|
}
|
|
377
473
|
|
|
378
474
|
try {
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
// Advance to next age in the game
|
|
382
|
-
await this.page.evaluate(() => {
|
|
383
|
-
window.dispatchEvent(new CustomEvent('replay-next-age'));
|
|
384
|
-
});
|
|
475
|
+
// Frame interval in ms (for continuous mode timing)
|
|
476
|
+
const frameIntervalMs = 1000 / config.fps;
|
|
385
477
|
|
|
386
|
-
|
|
387
|
-
|
|
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`);
|
|
388
482
|
|
|
389
|
-
|
|
390
|
-
for (let f = 0; f < framesPerAge; f++) {
|
|
483
|
+
for (let f = 0; f < totalFrames; f++) {
|
|
391
484
|
// Check if FFmpeg process has closed unexpectedly
|
|
392
485
|
if (ffmpegClosed) {
|
|
393
486
|
throw new Error('FFmpeg process closed unexpectedly');
|
|
@@ -417,7 +510,7 @@ export class PuppeteerRenderer {
|
|
|
417
510
|
|
|
418
511
|
frameIndex++;
|
|
419
512
|
|
|
420
|
-
// Update progress
|
|
513
|
+
// Update progress every second
|
|
421
514
|
if (frameIndex % config.fps === 0) {
|
|
422
515
|
this.reportProgress(
|
|
423
516
|
onProgress,
|
|
@@ -428,8 +521,68 @@ export class PuppeteerRenderer {
|
|
|
428
521
|
);
|
|
429
522
|
}
|
|
430
523
|
|
|
431
|
-
//
|
|
432
|
-
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
|
+
}
|
|
433
586
|
}
|
|
434
587
|
}
|
|
435
588
|
|