@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.
Files changed (46) hide show
  1. package/.cursor/dev.mdc +941 -0
  2. package/.cursor/project.mdc +17 -2
  3. package/.env +30 -0
  4. package/Dockerfile +6 -0
  5. package/README.md +297 -27
  6. package/dist/cli/render.js +14 -4
  7. package/dist/cli/render.js.map +1 -1
  8. package/dist/renderer/PuppeteerRenderer.d.ts +28 -2
  9. package/dist/renderer/PuppeteerRenderer.d.ts.map +1 -1
  10. package/dist/renderer/PuppeteerRenderer.js +134 -36
  11. package/dist/renderer/PuppeteerRenderer.js.map +1 -1
  12. package/dist/server/index.d.ts +4 -0
  13. package/dist/server/index.d.ts.map +1 -1
  14. package/dist/server/index.js +200 -46
  15. package/dist/server/index.js.map +1 -1
  16. package/dist/services/BundleManager.d.ts +99 -0
  17. package/dist/services/BundleManager.d.ts.map +1 -0
  18. package/dist/services/BundleManager.js +410 -0
  19. package/dist/services/BundleManager.js.map +1 -0
  20. package/dist/services/OSSClient.d.ts +51 -0
  21. package/dist/services/OSSClient.d.ts.map +1 -0
  22. package/dist/services/OSSClient.js +207 -0
  23. package/dist/services/OSSClient.js.map +1 -0
  24. package/dist/services/index.d.ts +7 -0
  25. package/dist/services/index.d.ts.map +1 -0
  26. package/dist/services/index.js +7 -0
  27. package/dist/services/index.js.map +1 -0
  28. package/dist/services/types.d.ts +73 -0
  29. package/dist/services/types.d.ts.map +1 -0
  30. package/dist/services/types.js +5 -0
  31. package/dist/services/types.js.map +1 -0
  32. package/docker-compose.local.yml +10 -0
  33. package/env.example +30 -0
  34. package/package.json +7 -3
  35. package/restart.sh +5 -0
  36. package/samples/jump_arena_5_ja-mksi5fku-qgk5iq.json +1952 -0
  37. package/scripts/render-pipeline.sh +657 -0
  38. package/scripts/test-bundle-preload.sh +20 -0
  39. package/scripts/test-service-sts.sh +176 -0
  40. package/src/cli/render.ts +18 -7
  41. package/src/renderer/PuppeteerRenderer.ts +192 -39
  42. package/src/server/index.ts +249 -68
  43. package/src/services/BundleManager.ts +503 -0
  44. package/src/services/OSSClient.ts +286 -0
  45. package/src/services/index.ts +7 -0
  46. 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<ReplayManifestV1> {
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
- console.log(` Timeline: ${manifest.timeline.length} ages`);
187
- console.log(` Highlights: ${manifest.highlights.length}`);
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 = manifest.timeline.length * args.secondsPerAge;
191
- console.log(` Estimated video duration: ${estimatedDuration}s`);
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
- import type { ReplayManifestV1 } from '@agent-foundry/replay';
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: ReplayManifestV1, config: RenderConfig): Promise<RenderResult> {
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
- const frameCount = manifest.timeline.length * fullConfig.secondsPerAge * fullConfig.fps;
104
- this.reportProgress(config.onProgress, 'capture', 0, frameCount, 'Starting capture & encode...');
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: ReplayManifestV1): Promise<void> {
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 (check for the app container)
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
- // Wait for the app to be mounted and initial loading to complete
201
- const app = document.querySelector('ion-app');
202
- const loading = document.querySelector('ion-spinner');
203
- return app !== null && loading === null;
204
- }, { timeout: 15000 });
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
- // Log errors before failing
207
- if (this.pageErrors.length > 0) {
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: ReplayManifestV1,
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: ReplayManifestV1,
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 totalAges = manifest.timeline.length;
301
- const framesPerAge = config.secondsPerAge * config.fps;
302
- const totalFrames = totalAges * framesPerAge;
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
- // Capture and stream frames
380
- for (let ageIndex = 0; ageIndex < totalAges; ageIndex++) {
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
- // Wait a bit for animation
387
- 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`);
388
482
 
389
- // Capture frames for this age
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
- // Small delay between frames
432
- 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
+ }
433
586
  }
434
587
  }
435
588