ravensight-playtest 0.3.2 → 0.5.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.
- package/README.md +77 -2
- package/docs/SIMULATION.md +91 -0
- package/docs/godot-driver.md +310 -0
- package/examples/simulation/economy.mjs +22 -0
- package/package.json +7 -2
- package/src/api/client.js +2 -2
- package/src/cli.js +21 -1
- package/src/commands/run.js +42 -13
- package/src/commands/simulate.js +298 -0
- package/src/run/actionSelector.js +3 -2
- package/src/run/aggregate.js +4 -1
- package/src/run/args.js +2 -0
- package/src/run/comparison.js +32 -0
- package/src/run/controllers.js +17 -0
- package/src/run/drivers/driver.js +5 -5
- package/src/run/drivers/godot.js +131 -39
- package/src/run/drivers/recording.js +148 -0
- package/src/run/drivers/web.js +128 -66
- package/src/run/personaLoop.js +17 -10
- package/src/run/resume.js +16 -5
- package/src/simulation/index.js +176 -0
- package/src/simulation/protocol.js +90 -0
- package/src/upload/index.js +4 -2
- package/src/upload/recording.js +56 -0
package/src/run/drivers/godot.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Both of the differences ./driver.js names fall on this driver's side: a
|
|
11
11
|
// string `click` target is a scene tree node path, not an accessibility ref,
|
|
12
|
-
// and
|
|
13
|
-
//
|
|
12
|
+
// and recording uses the selected viewport, with optional local ffmpeg encoding.
|
|
13
|
+
// Without an encoder or a rendered viewport it reports unsupported, never a video.
|
|
14
14
|
//
|
|
15
15
|
// What happens at launch, in order:
|
|
16
16
|
// 1. find the developer's own Godot binary (spec 17 detection order),
|
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
prepareProjectCopy,
|
|
35
35
|
} from './godot-project.js';
|
|
36
36
|
import { DriverRpcError, GodotRpcClient } from './godot-rpc.js';
|
|
37
|
+
import { detectFfmpeg } from '../../detect.js';
|
|
38
|
+
import { recordingLimits, recordingViewport, recordingDirectory, recordingMetadata, saveRecording, encodeFrames } from './recording.js';
|
|
37
39
|
|
|
38
40
|
export const DRIVER_NAME = 'godot_driver';
|
|
39
41
|
export const BOOT_TIMEOUT_MS = 60000;
|
|
@@ -124,6 +126,9 @@ export class GodotDriver {
|
|
|
124
126
|
prepare = prepareProjectCopy,
|
|
125
127
|
choosePort = findFreePort,
|
|
126
128
|
now = () => Date.now(),
|
|
129
|
+
recording = {},
|
|
130
|
+
detectEncoder = detectFfmpeg,
|
|
131
|
+
encode = encodeFrames,
|
|
127
132
|
} = {}) {
|
|
128
133
|
this.name = DRIVER_NAME;
|
|
129
134
|
this.projectDir = projectDir;
|
|
@@ -148,6 +153,11 @@ export class GodotDriver {
|
|
|
148
153
|
this._prepare = prepare;
|
|
149
154
|
this._choosePort = choosePort;
|
|
150
155
|
this._now = now;
|
|
156
|
+
this._recordingLimits = recordingLimits(recording);
|
|
157
|
+
this._detectEncoder = detectEncoder;
|
|
158
|
+
this._encode = encode;
|
|
159
|
+
this._frames = [];
|
|
160
|
+
this._frameBytes = 0;
|
|
151
161
|
|
|
152
162
|
this.rpc = null;
|
|
153
163
|
this.child = null;
|
|
@@ -178,8 +188,13 @@ export class GodotDriver {
|
|
|
178
188
|
return !this._stopped && this.exitCode !== null && this.exitCode !== 0;
|
|
179
189
|
}
|
|
180
190
|
|
|
181
|
-
async launch({ recordVideo = false, viewport } = {}) {
|
|
191
|
+
async launch({ recordVideo = false, viewport, signal } = {}) {
|
|
192
|
+
if (signal?.aborted) throw new Error('run canceled before game launch');
|
|
182
193
|
if (viewport) this.viewport = viewport;
|
|
194
|
+
if (recordVideo) {
|
|
195
|
+
const size = recordingViewport(this.viewport, this._recordingLimits);
|
|
196
|
+
this.viewport = { w: size.width, h: size.height };
|
|
197
|
+
}
|
|
183
198
|
if (!this.projectDir) throw new Error('godot driver needs a projectDir');
|
|
184
199
|
this.godot = this.godotPath
|
|
185
200
|
? await this._detect({ explicitPath: this.godotPath, env: this.env, projectDir: this.projectDir })
|
|
@@ -198,6 +213,7 @@ export class GodotDriver {
|
|
|
198
213
|
this.logPath = this.runDir ? path.join(this.runDir, 'godot.log') : null;
|
|
199
214
|
|
|
200
215
|
const args = ['--path', this.projectCopy];
|
|
216
|
+
if (recordVideo) args.push('--audio-driver', 'Dummy');
|
|
201
217
|
if (this.headless) args.push('--headless');
|
|
202
218
|
else args.push('--resolution', `${this.viewport.w}x${this.viewport.h}`);
|
|
203
219
|
args.push('--', '--ravensight-driver', `--ravensight-driver-port=${this.port}`);
|
|
@@ -226,6 +242,10 @@ export class GodotDriver {
|
|
|
226
242
|
}
|
|
227
243
|
|
|
228
244
|
if (recordVideo) await this._startFrameCapture();
|
|
245
|
+
this._signal = signal;
|
|
246
|
+
this._onAbort = () => { void this.stop({ reason: 'canceled' }).catch(() => {}); };
|
|
247
|
+
signal?.addEventListener('abort', this._onAbort, { once: true });
|
|
248
|
+
if (signal?.aborted) { await this.stop({ reason: 'canceled' }); throw new Error('run canceled during game launch'); }
|
|
229
249
|
return {};
|
|
230
250
|
} catch (error) {
|
|
231
251
|
// Everything past this point has already spawned a game and copied a
|
|
@@ -289,6 +309,10 @@ export class GodotDriver {
|
|
|
289
309
|
this.exitCode = code;
|
|
290
310
|
this.exitSignal = signal;
|
|
291
311
|
if (log) log.end();
|
|
312
|
+
if (!this._stopped && this._recordingStartedAt != null) {
|
|
313
|
+
this._endCapture(code === 0 ? 'game_exited' : 'game_crashed');
|
|
314
|
+
void this._finalizeRecording().catch(() => {});
|
|
315
|
+
}
|
|
292
316
|
});
|
|
293
317
|
this.child.on('error', (error) => {
|
|
294
318
|
this._consoleQueue.push({ kind: 'driver', text: `failed to launch Godot: ${error.message}` });
|
|
@@ -501,43 +525,114 @@ export class GodotDriver {
|
|
|
501
525
|
this.chapters.push({ title, at: this._startedAt === null ? 0 : this._now() - this._startedAt });
|
|
502
526
|
}
|
|
503
527
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
this.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
528
|
+
// Capture only this project's viewport. No screen/audio device is opened.
|
|
529
|
+
async _startFrameCapture() {
|
|
530
|
+
this._recordingStartedAt = this._now();
|
|
531
|
+
if (this.runDir) this._framesDir = await recordingDirectory(this.runDir);
|
|
532
|
+
if (this.headless || !this.runDir) {
|
|
533
|
+
this._recordingUnsupported = this.headless ? 'headless_no_viewport' : 'run_directory_required';
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
this._encoder = await this._detectEncoder();
|
|
537
|
+
if (!this._encoder?.path) {
|
|
538
|
+
this._recordingUnsupported = 'encoder_unavailable';
|
|
539
|
+
this._consoleQueue.push({ kind: 'driver', text: 'Video unavailable: install optional ffmpeg or set RAVENSIGHT_FFMPEG. No video was recorded.' });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
this._recordingStartedAt = this._now();
|
|
543
|
+
await this._captureFrame();
|
|
544
|
+
if (this._captureEndedAt != null) return;
|
|
545
|
+
this._videoTimer = setInterval(() => { void this._captureFrame(); }, Math.round(1000 / this._recordingLimits.fps));
|
|
546
|
+
this._videoTimer.unref?.();
|
|
547
|
+
this._videoDeadline = setTimeout(() => this._endCapture('duration_limit'), this._recordingLimits.durationMs);
|
|
548
|
+
this._videoDeadline.unref?.();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
_endCapture(reason) {
|
|
552
|
+
if (this._captureEndedAt != null) return;
|
|
553
|
+
this._captureEndedAt = Math.min(this._now(), this._recordingStartedAt + this._recordingLimits.durationMs);
|
|
554
|
+
this._captureReason = reason;
|
|
555
|
+
clearInterval(this._videoTimer);
|
|
556
|
+
clearTimeout(this._videoDeadline);
|
|
557
|
+
this._videoTimer = null;
|
|
517
558
|
}
|
|
518
559
|
|
|
519
560
|
async _captureFrame() {
|
|
520
|
-
if (this.
|
|
521
|
-
this.
|
|
522
|
-
|
|
523
|
-
const shot = await this._rawScreenshot();
|
|
524
|
-
if (!shot) return;
|
|
525
|
-
this._frameIndex += 1;
|
|
526
|
-
const file = path.join(this._framesDir, `${String(this._frameIndex).padStart(6, '0')}.png`);
|
|
527
|
-
await fs.writeFile(file, Buffer.from(shot.data, 'base64'));
|
|
528
|
-
} catch {
|
|
529
|
-
// A dropped frame is never worth interrupting a run for.
|
|
530
|
-
} finally {
|
|
531
|
-
this._capturing = false;
|
|
561
|
+
if (this._capturePromise || this._stopped || this._captureEndedAt != null) return;
|
|
562
|
+
if (this._now() - this._recordingStartedAt >= this._recordingLimits.durationMs) {
|
|
563
|
+
this._endCapture('duration_limit'); return;
|
|
532
564
|
}
|
|
565
|
+
const at = this._now();
|
|
566
|
+
this._capturePromise = (async () => {
|
|
567
|
+
try {
|
|
568
|
+
const shot = await this._rawScreenshot();
|
|
569
|
+
if (!shot) return;
|
|
570
|
+
const max = this._recordingLimits.singleFrameBytes;
|
|
571
|
+
if (typeof shot.data !== 'string' || shot.data.length > Math.ceil(max / 3) * 4) {
|
|
572
|
+
this._endCapture('frame_size_limit'); return;
|
|
573
|
+
}
|
|
574
|
+
const bytes = Buffer.from(shot.data, 'base64');
|
|
575
|
+
if (bytes.length > max || this._frameBytes + bytes.length > this._recordingLimits.frameBytes) {
|
|
576
|
+
this._endCapture('frame_bytes_limit'); return;
|
|
577
|
+
}
|
|
578
|
+
const name = `${String(this._frameIndex + 1).padStart(6, '0')}.png`;
|
|
579
|
+
await fs.writeFile(path.join(this._framesDir, name), bytes, { flag: 'wx', mode: 0o600 });
|
|
580
|
+
this._frames.push({ name, at });
|
|
581
|
+
this._frameIndex += 1;
|
|
582
|
+
this._frameBytes += bytes.length;
|
|
583
|
+
} catch {
|
|
584
|
+
// A disconnected/crashed game can still leave a useful partial recording.
|
|
585
|
+
this._endCapture('capture_failed');
|
|
586
|
+
}
|
|
587
|
+
})();
|
|
588
|
+
try { await this._capturePromise; } finally { this._capturePromise = null; }
|
|
533
589
|
}
|
|
534
590
|
|
|
535
|
-
|
|
591
|
+
_finalizeRecording() {
|
|
592
|
+
if (this._finalizePromise) return this._finalizePromise;
|
|
593
|
+
this._finalizePromise = (async () => {
|
|
594
|
+
if (this._recordingStartedAt == null) return {};
|
|
595
|
+
await this._capturePromise;
|
|
596
|
+
const endedAt = this._captureEndedAt ?? this._now();
|
|
597
|
+
let metadata = recordingMetadata({ driver: this.name, startedAt: this._recordingStartedAt, endedAt,
|
|
598
|
+
reason: this._recordingUnsupported ?? this._captureReason ?? 'stopped',
|
|
599
|
+
status: this._recordingUnsupported ? 'unsupported' : 'failed', frameCount: this._frameIndex,
|
|
600
|
+
captureFpsLimit: this._recordingLimits.fps, timeOrigin: 'first-viewport-frame',
|
|
601
|
+
});
|
|
602
|
+
let videoPath = null;
|
|
603
|
+
if (!this._recordingUnsupported && this._framesDir) {
|
|
604
|
+
try {
|
|
605
|
+
const encoded = await this._encode({ directory: this._framesDir, frames: this._frames, endedAt,
|
|
606
|
+
encoder: this._encoder, limits: this._recordingLimits });
|
|
607
|
+
videoPath = path.join(this.runDir, 'session.mp4');
|
|
608
|
+
await fs.rename(encoded.file, videoPath);
|
|
609
|
+
metadata = { ...metadata, status: ['stopped', 'completed', 'game_exited'].includes(metadata.reason) ? 'complete' : 'partial',
|
|
610
|
+
relativePath: 'session.mp4', mimeType: encoded.mimeType, bytes: encoded.bytes,
|
|
611
|
+
durationMs: encoded.durationMs, durationSource: encoded.durationSource ?? 'frame-timestamps',
|
|
612
|
+
firstFrameOffsetMs: this._frames[0].at - this._recordingStartedAt };
|
|
613
|
+
if (encoded.truncated) { metadata.status = 'partial'; metadata.reason = 'size_limit'; }
|
|
614
|
+
} catch (error) {
|
|
615
|
+
metadata.reason = ['no_frames', 'encoder_unavailable', 'encoding_timeout', 'encoding_failed'].includes(error.message) ? error.message : 'encoding_failed';
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Captured game frames are local temporary data, never upload artifacts.
|
|
619
|
+
if (this._framesDir) await fs.rm(this._framesDir, { recursive: true, force: true });
|
|
620
|
+
if (this.runDir) await saveRecording(this.runDir, metadata);
|
|
621
|
+
return { videoPath, recording: metadata };
|
|
622
|
+
})();
|
|
623
|
+
return this._finalizePromise;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
stop(options = {}) {
|
|
627
|
+
if (!this._stopPromise) this._stopPromise = Promise.resolve().then(() => this._stop(options));
|
|
628
|
+
return this._stopPromise;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async _stop({ removeTemp = !this.keepTemp, reason = 'stopped' } = {}) {
|
|
536
632
|
this._stopped = true;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
633
|
+
this._signal?.removeEventListener('abort', this._onAbort);
|
|
634
|
+
if (this._recordingStartedAt != null) this._endCapture(reason);
|
|
635
|
+
await this._capturePromise;
|
|
541
636
|
if (this.rpc && !this.rpc.closed) {
|
|
542
637
|
await this.rpc.call('quit', { code: 0 }, { timeoutMs: 2000 }).catch(() => {});
|
|
543
638
|
}
|
|
@@ -549,12 +644,9 @@ export class GodotDriver {
|
|
|
549
644
|
this.projectCopy = null;
|
|
550
645
|
}
|
|
551
646
|
return {
|
|
552
|
-
videoPath: null,
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
chapters: this.chapters,
|
|
556
|
-
logPath: this.logPath,
|
|
557
|
-
exitCode: this.exitCode,
|
|
647
|
+
videoPath: null, ...await this._finalizeRecording(),
|
|
648
|
+
frameCount: this._frameIndex, chapters: this.chapters,
|
|
649
|
+
logPath: this.logPath, exitCode: this.exitCode,
|
|
558
650
|
};
|
|
559
651
|
}
|
|
560
652
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Local recording only. This module never uploads, reads credentials, or captures
|
|
2
|
+
// a display/audio device. Final files use the existing opt-in upload allowlist.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { detectFfmpeg } from '../../detect.js';
|
|
8
|
+
import { MAX_VIDEO_BYTES } from '../../upload/allowlist.js';
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
export const RECORDING_LIMITS = Object.freeze({
|
|
12
|
+
durationMs: 15 * 60 * 1000,
|
|
13
|
+
videoBytes: MAX_VIDEO_BYTES,
|
|
14
|
+
frameBytes: 256 * 1024 * 1024,
|
|
15
|
+
singleFrameBytes: 4 * 1024 * 1024,
|
|
16
|
+
width: 1280,
|
|
17
|
+
height: 720,
|
|
18
|
+
fps: 10,
|
|
19
|
+
finalizeMs: 60000,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Test seams may lower limits, never raise production ceilings.
|
|
23
|
+
export function recordingLimits(overrides = {}) {
|
|
24
|
+
const out = { ...RECORDING_LIMITS };
|
|
25
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
26
|
+
if (!(key in out) || !Number.isSafeInteger(value) || value < 1 || value > out[key]) {
|
|
27
|
+
throw new Error(`invalid recording limit: ${key}`);
|
|
28
|
+
}
|
|
29
|
+
out[key] = value;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function recordingViewport(viewport, limits = RECORDING_LIMITS) {
|
|
35
|
+
const width = Number(viewport?.width ?? viewport?.w ?? limits.width);
|
|
36
|
+
const height = Number(viewport?.height ?? viewport?.h ?? limits.height);
|
|
37
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) {
|
|
38
|
+
throw new Error('recording viewport must have positive integer dimensions');
|
|
39
|
+
}
|
|
40
|
+
const scale = Math.min(1, limits.width / width, limits.height / height);
|
|
41
|
+
return { width: Math.max(1, Math.floor(width * scale)), height: Math.max(1, Math.floor(height * scale)) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function recordingDirectory(runDir) {
|
|
45
|
+
await fs.mkdir(runDir, { recursive: true });
|
|
46
|
+
// Reusing an old playable recording after a failed capture is worse than
|
|
47
|
+
// reporting no recording. Do not silently replace it either.
|
|
48
|
+
for (const name of ['session.webm', 'session.mp4', 'recording.json']) {
|
|
49
|
+
if (await fs.lstat(path.join(runDir, name)).then(() => true, e => { if (e.code === 'ENOENT') return false; throw e; })) {
|
|
50
|
+
throw new Error(`recording output already exists: ${name}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const directory = await fs.mkdtemp(path.join(runDir, '.recording-'));
|
|
54
|
+
await fs.chmod(directory, 0o700);
|
|
55
|
+
return directory;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function recordingMetadata({ driver, startedAt, endedAt, reason = 'stopped', ...details }) {
|
|
59
|
+
return {
|
|
60
|
+
schemaVersion: 1, driver, status: 'failed', audio: false,
|
|
61
|
+
startedAt: new Date(startedAt).toISOString(), endedAt: new Date(endedAt).toISOString(),
|
|
62
|
+
durationMs: Math.max(0, endedAt - startedAt), durationSource: 'capture-clock',
|
|
63
|
+
relativePath: null, mimeType: null, bytes: 0, reason, stopReason: reason, ...details,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function saveRecording(runDir, metadata) {
|
|
68
|
+
const pending = path.join(runDir, '.recording.json.tmp');
|
|
69
|
+
await fs.writeFile(pending, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
70
|
+
await fs.rename(pending, path.join(runDir, 'recording.json'));
|
|
71
|
+
return metadata;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function videoInfo(file, { limits = RECORDING_LIMITS } = {}) {
|
|
75
|
+
const stat = await fs.lstat(file);
|
|
76
|
+
if (!stat.isFile() || stat.size < 16 || stat.size > limits.videoBytes) throw new Error('invalid_video_size');
|
|
77
|
+
const handle = await fs.open(file, 'r');
|
|
78
|
+
const head = Buffer.alloc(16);
|
|
79
|
+
try { await handle.read(head, 0, head.length, 0); } finally { await handle.close(); }
|
|
80
|
+
const webm = head.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
|
|
81
|
+
const mp4 = head.toString('ascii', 4, 8) === 'ftyp';
|
|
82
|
+
if (!webm && !mp4) throw new Error('invalid_video_container');
|
|
83
|
+
return { bytes: stat.size, mimeType: webm ? 'video/webm' : 'video/mp4' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ffprobe is optional for Playwright, whose successful close finalizes WebM.
|
|
87
|
+
// When installed, verify the actual media timeline and absence of audio. A
|
|
88
|
+
// present tool rejecting the media is a failure, never a fallback success.
|
|
89
|
+
export async function probeVideo(file, { limits = RECORDING_LIMITS, run = execute } = {}) {
|
|
90
|
+
const info = await videoInfo(file, { limits });
|
|
91
|
+
const binary = process.env.RAVENSIGHT_FFPROBE || (process.env.RAVENSIGHT_FFMPEG
|
|
92
|
+
? path.join(path.dirname(process.env.RAVENSIGHT_FFMPEG), process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe') : 'ffprobe');
|
|
93
|
+
let stdout;
|
|
94
|
+
try {
|
|
95
|
+
({ stdout } = await run(binary, ['-v', 'error', '-show_entries', 'stream=codec_type,width,height:format=duration', '-of', 'json', file],
|
|
96
|
+
{ timeout: 10000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true }));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.code === 'ENOENT') return info;
|
|
99
|
+
throw new Error('invalid_video_media');
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const media = JSON.parse(stdout);
|
|
103
|
+
const video = media.streams.filter(stream => stream.codec_type === 'video');
|
|
104
|
+
const durationMs = Math.round(Number(media.format.duration) * 1000);
|
|
105
|
+
if (video.length !== 1 || media.streams.some(stream => stream.codec_type === 'audio')
|
|
106
|
+
|| !Number.isSafeInteger(durationMs) || durationMs <= 0 || durationMs > limits.durationMs
|
|
107
|
+
|| video[0].width > limits.width || video[0].height > limits.height) throw new Error();
|
|
108
|
+
return { ...info, durationMs, durationSource: 'media', width: video[0].width, height: video[0].height };
|
|
109
|
+
} catch { throw new Error('invalid_video_media'); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Encode only numbered PNGs we wrote, with their actual capture timestamps. */
|
|
113
|
+
export async function encodeFrames({ directory, frames, endedAt, limits = RECORDING_LIMITS, encoder, run = execute, inspect = probeVideo }) {
|
|
114
|
+
if (!frames.length) throw new Error('no_frames');
|
|
115
|
+
const detected = encoder ?? await detectFfmpeg();
|
|
116
|
+
if (!detected.path) throw new Error('encoder_unavailable');
|
|
117
|
+
const first = frames[0].at;
|
|
118
|
+
const durationMs = Math.min(limits.durationMs, Math.max(1000 / limits.fps, endedAt - first));
|
|
119
|
+
const lines = ['ffconcat version 1.0'];
|
|
120
|
+
for (let i = 0; i < frames.length; i++) {
|
|
121
|
+
const frame = frames[i];
|
|
122
|
+
if (!/^\d{6}\.png$/.test(frame.name) || !Number.isFinite(frame.at) || (i && frame.at < frames[i - 1].at)) {
|
|
123
|
+
throw new Error('invalid_frame_timeline');
|
|
124
|
+
}
|
|
125
|
+
const end = Math.min(first + durationMs, frames[i + 1]?.at ?? first + durationMs);
|
|
126
|
+
lines.push(`file '${frame.name}'`, `duration ${Math.max(0.001, (end - frame.at) / 1000).toFixed(6)}`);
|
|
127
|
+
}
|
|
128
|
+
lines.push(`file '${frames.at(-1).name}'`);
|
|
129
|
+
const list = path.join(directory, 'frames.ffconcat');
|
|
130
|
+
await fs.writeFile(list, `${lines.join('\n')}\n`, { mode: 0o600, flag: 'wx' });
|
|
131
|
+
const output = path.join(directory, 'encoded.mp4');
|
|
132
|
+
try {
|
|
133
|
+
await run(detected.path, [
|
|
134
|
+
'-nostdin', '-hide_banner', '-loglevel', 'error', '-n',
|
|
135
|
+
'-protocol_whitelist', 'file', '-f', 'concat', '-safe', '1', '-i', list,
|
|
136
|
+
'-an', '-vf', `scale=${limits.width}:${limits.height}:force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2,setsar=1`,
|
|
137
|
+
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p',
|
|
138
|
+
'-r', '30', '-t', (durationMs / 1000).toFixed(3), '-fs', String(limits.videoBytes - 4096),
|
|
139
|
+
'-movflags', '+faststart', output,
|
|
140
|
+
], { cwd: directory, timeout: limits.finalizeMs, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true });
|
|
141
|
+
const info = await inspect(output, { limits });
|
|
142
|
+
return { file: output, durationMs, ...info, truncated: info.durationMs != null && info.durationMs + 150 < durationMs };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await fs.rm(output, { force: true });
|
|
145
|
+
// Never include process output, a game path, or inherited environment in a report.
|
|
146
|
+
throw new Error(error.killed ? 'encoding_timeout' : 'encoding_failed');
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/run/drivers/web.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdir, rename, stat, readdir } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, rename, stat, readdir, rm, chmod } from 'node:fs/promises';
|
|
2
|
+
import { recordingLimits, recordingViewport, recordingDirectory, recordingMetadata, saveRecording, probeVideo } from './recording.js';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import {
|
|
4
5
|
DriverError,
|
|
@@ -1121,7 +1122,7 @@ export const CHECK_PLAYTEST_IDENTITY = Object.freeze({ token: 'check', runId: nu
|
|
|
1121
1122
|
* the one the platform implies
|
|
1122
1123
|
* @returns {Object} a driver
|
|
1123
1124
|
*/
|
|
1124
|
-
export function createWebDriver({ importer, headless = true, playtest = null, platform = process.platform, modifier = null } = {}) {
|
|
1125
|
+
export function createWebDriver({ importer, headless = true, playtest = null, platform = process.platform, modifier = null, recording = {}, now = () => Date.now(), inspectVideo = probeVideo } = {}) {
|
|
1125
1126
|
const editModifier = modifier === 'Meta' || modifier === 'Control' ? modifier : platformModifier(platform);
|
|
1126
1127
|
let browser = null;
|
|
1127
1128
|
let context = null;
|
|
@@ -1129,6 +1130,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1129
1130
|
let target = null;
|
|
1130
1131
|
let runDirectory = null;
|
|
1131
1132
|
let videoRequested = false;
|
|
1133
|
+
const limits = recordingLimits(recording);
|
|
1134
|
+
let captureDirectory = null;
|
|
1135
|
+
let captureStartedAt = null;
|
|
1136
|
+
let stopPromise = null;
|
|
1137
|
+
let captureTimer = null;
|
|
1138
|
+
let captureDeadline = null;
|
|
1139
|
+
let runSignal = null;
|
|
1140
|
+
let onAbort = null;
|
|
1141
|
+
let inspecting = false;
|
|
1142
|
+
let terminalReason = null;
|
|
1132
1143
|
let step = 0;
|
|
1133
1144
|
let shots = 0;
|
|
1134
1145
|
let lastHash = null;
|
|
@@ -1344,6 +1355,77 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1344
1355
|
return observationHash(after);
|
|
1345
1356
|
}
|
|
1346
1357
|
|
|
1358
|
+
function stop({ reason = 'stopped' } = {}) {
|
|
1359
|
+
if (stopPromise) return stopPromise;
|
|
1360
|
+
terminalReason ??= reason;
|
|
1361
|
+
stopPromise = Promise.resolve().then(finalize);
|
|
1362
|
+
return stopPromise;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function finalize() {
|
|
1366
|
+
clearInterval(captureTimer);
|
|
1367
|
+
clearTimeout(captureDeadline);
|
|
1368
|
+
runSignal?.removeEventListener('abort', onAbort);
|
|
1369
|
+
const endedAt = now();
|
|
1370
|
+
let videoPath = null;
|
|
1371
|
+
let promotedPath = null;
|
|
1372
|
+
let finalized = true;
|
|
1373
|
+
// Only the explicitly selected page's handle may become session.webm.
|
|
1374
|
+
const video = videoRequested && page && typeof page.video === 'function' ? page.video() : null;
|
|
1375
|
+
try { if (context) await withTimeout(context.close(), 15000); } catch { finalized = false; }
|
|
1376
|
+
let metadata = null;
|
|
1377
|
+
if (videoRequested && captureStartedAt != null) {
|
|
1378
|
+
metadata = recordingMetadata({ driver: 'playwright_web', startedAt: captureStartedAt, endedAt,
|
|
1379
|
+
reason: terminalReason, timeOrigin: 'page-creation-request' });
|
|
1380
|
+
if (video) {
|
|
1381
|
+
try {
|
|
1382
|
+
let source;
|
|
1383
|
+
if (typeof video.saveAs === 'function') {
|
|
1384
|
+
source = path.join(captureDirectory, 'selected-final.webm');
|
|
1385
|
+
await withTimeout(video.saveAs(source), 15000);
|
|
1386
|
+
} else source = await withTimeout(video.path(), 15000);
|
|
1387
|
+
// No arbitrary run-directory scan or old-file fallback on a crash.
|
|
1388
|
+
if (path.dirname(path.resolve(source)) !== path.resolve(captureDirectory)) throw new Error('foreign_video_path');
|
|
1389
|
+
const info = await inspectVideo(source, { limits });
|
|
1390
|
+
if (info.mimeType !== 'video/webm') throw new Error('invalid_video_container');
|
|
1391
|
+
const destination = path.join(runDirectory, 'session.webm');
|
|
1392
|
+
await rename(source, destination);
|
|
1393
|
+
promotedPath = destination;
|
|
1394
|
+
await chmod(destination, 0o600);
|
|
1395
|
+
videoPath = destination;
|
|
1396
|
+
metadata = { ...metadata, ...info, relativePath: 'session.webm',
|
|
1397
|
+
status: finalized && ['stopped', 'completed'].includes(terminalReason) ? 'complete' : 'partial' };
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
if (promotedPath) await rm(promotedPath, { force: true });
|
|
1400
|
+
videoPath = null;
|
|
1401
|
+
metadata.reason = 'video_finalization_failed';
|
|
1402
|
+
metadata.failure = ['invalid_video_size', 'invalid_video_container', 'invalid_video_media', 'foreign_video_path'].includes(error.message) ? error.message : 'recorder_unavailable';
|
|
1403
|
+
}
|
|
1404
|
+
} else metadata.reason = 'video_unavailable';
|
|
1405
|
+
}
|
|
1406
|
+
try { if (browser) await withTimeout(browser.close(), 15000); } catch { /* already closed or unavailable */ }
|
|
1407
|
+
if (captureDirectory) await rm(captureDirectory, { recursive: true, force: true });
|
|
1408
|
+
if (metadata) await saveRecording(runDirectory, metadata);
|
|
1409
|
+
page = null;
|
|
1410
|
+
context = null;
|
|
1411
|
+
browser = null;
|
|
1412
|
+
return { videoPath, ...(metadata ? { recording: metadata } : {}) };
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
async function inspectCapture() {
|
|
1416
|
+
if (inspecting || stopPromise || !captureDirectory) return;
|
|
1417
|
+
inspecting = true;
|
|
1418
|
+
try {
|
|
1419
|
+
let bytes = 0;
|
|
1420
|
+
for (const name of await readdir(captureDirectory)) {
|
|
1421
|
+
bytes += (await stat(path.join(captureDirectory, name))).size;
|
|
1422
|
+
}
|
|
1423
|
+
// Leave room for WebM's final index; oversize output is never published.
|
|
1424
|
+
if (bytes >= Math.max(1, limits.videoBytes - 8 * 1024 * 1024)) await stop({ reason: 'size_limit' });
|
|
1425
|
+
} catch { await stop({ reason: 'capture_failed' }); }
|
|
1426
|
+
finally { inspecting = false; }
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1347
1429
|
return {
|
|
1348
1430
|
name: 'playwright_web',
|
|
1349
1431
|
supportsRefs: true,
|
|
@@ -1357,20 +1439,26 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1357
1439
|
* @param {boolean} [opts.recordVideo]
|
|
1358
1440
|
* @param {{width: number, height: number}} [opts.viewport]
|
|
1359
1441
|
*/
|
|
1360
|
-
async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT }) {
|
|
1442
|
+
async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT, signal }) {
|
|
1361
1443
|
if (!url) throw new DriverError('playwright_web needs a target URL', { fatal: true });
|
|
1444
|
+
if (signal?.aborted) throw new DriverError('run canceled before browser launch');
|
|
1362
1445
|
target = url;
|
|
1363
1446
|
viewportSize = viewport || DEFAULT_VIEWPORT;
|
|
1364
1447
|
runDirectory = runDir;
|
|
1365
1448
|
videoRequested = Boolean(recordVideo);
|
|
1449
|
+
if (videoRequested) {
|
|
1450
|
+
viewport = recordingViewport(viewport, limits);
|
|
1451
|
+
viewportSize = viewport;
|
|
1452
|
+
captureDirectory = await recordingDirectory(runDir);
|
|
1453
|
+
}
|
|
1366
1454
|
|
|
1455
|
+
try {
|
|
1367
1456
|
const playwright = await loadPlaywright(importer);
|
|
1368
1457
|
await mkdir(runDir, { recursive: true });
|
|
1369
|
-
|
|
1370
|
-
browser = await playwright.chromium.launch({ headless });
|
|
1458
|
+
browser = await playwright.chromium.launch({ headless, ...(videoRequested ? { args: ['--mute-audio'] } : {}) });
|
|
1371
1459
|
context = await browser.newContext({
|
|
1372
1460
|
viewport,
|
|
1373
|
-
...(videoRequested ? { recordVideo: { dir:
|
|
1461
|
+
...(videoRequested ? { recordVideo: { dir: captureDirectory, size: viewport }, permissions: [] } : {})
|
|
1374
1462
|
});
|
|
1375
1463
|
// The playtest identity goes in as an init script, not as
|
|
1376
1464
|
// `extraHTTPHeaders`: a context header rides on every request the page
|
|
@@ -1387,7 +1475,34 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1387
1475
|
// One token per document: set again by every new document, kept by
|
|
1388
1476
|
// pushState and hash route changes, which do not run init scripts.
|
|
1389
1477
|
if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
|
|
1478
|
+
if (videoRequested && typeof context.route === 'function') {
|
|
1479
|
+
const origin = new URL(url).origin;
|
|
1480
|
+
await context.route('**/*', route => {
|
|
1481
|
+
const request = route.request();
|
|
1482
|
+
if (request.isNavigationRequest() && !request.frame().parentFrame() && new URL(request.url()).origin !== origin) {
|
|
1483
|
+
return route.abort('blockedbyclient');
|
|
1484
|
+
}
|
|
1485
|
+
return route.continue();
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
captureStartedAt = videoRequested ? now() : null;
|
|
1390
1489
|
page = await context.newPage();
|
|
1490
|
+
if (videoRequested) {
|
|
1491
|
+
// Context video may briefly allocate a popup file; close it immediately
|
|
1492
|
+
// and discard all non-selected files during finalization.
|
|
1493
|
+
page.on('popup', popup => { void popup.close().catch(() => {}); });
|
|
1494
|
+
page.on('crash', () => { void stop({ reason: 'game_crashed' }).catch(() => {}); });
|
|
1495
|
+
page.on('close', () => { if (!stopPromise) void stop({ reason: 'page_closed' }).catch(() => {}); });
|
|
1496
|
+
captureTimer = setInterval(() => { void inspectCapture(); }, 250);
|
|
1497
|
+
captureTimer.unref?.();
|
|
1498
|
+
captureDeadline = setTimeout(() => { void stop({ reason: 'duration_limit' }).catch(() => {}); }, limits.durationMs);
|
|
1499
|
+
captureDeadline.unref?.();
|
|
1500
|
+
}
|
|
1501
|
+
runSignal = signal;
|
|
1502
|
+
onAbort = () => { void stop({ reason: 'canceled' }).catch(() => {}); };
|
|
1503
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1504
|
+
if (signal?.aborted) { await stop({ reason: 'canceled' }); throw new DriverError('run canceled during browser launch'); }
|
|
1505
|
+
|
|
1391
1506
|
page.on('console', message => {
|
|
1392
1507
|
if (message.type() !== 'error') return;
|
|
1393
1508
|
let location = null;
|
|
@@ -1426,6 +1541,11 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1426
1541
|
if (response && response.status() >= 400) {
|
|
1427
1542
|
throw new DriverError(`${url} answered ${response.status()}`, { fatal: true });
|
|
1428
1543
|
}
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
await stop({ reason: 'launch_failed' }).catch(() => {});
|
|
1546
|
+
if (captureDirectory) await rm(captureDirectory, { recursive: true, force: true }).catch(() => {});
|
|
1547
|
+
throw error;
|
|
1548
|
+
}
|
|
1429
1549
|
},
|
|
1430
1550
|
|
|
1431
1551
|
/** @returns {Promise<import('./driver.js').Observation>} */
|
|
@@ -1598,33 +1718,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1598
1718
|
return attachNetworkFailures(consoleBuffer.splice(0, consoleBuffer.length), networkFailures);
|
|
1599
1719
|
},
|
|
1600
1720
|
|
|
1601
|
-
/**
|
|
1602
|
-
|
|
1603
|
-
let videoPath = null;
|
|
1604
|
-
const video = videoRequested && page && typeof page.video === 'function' ? page.video() : null;
|
|
1605
|
-
try {
|
|
1606
|
-
if (context) await context.close();
|
|
1607
|
-
} catch {
|
|
1608
|
-
// A context that is already gone is not an error worth failing the
|
|
1609
|
-
// run over; the report is already written by the time we get here.
|
|
1610
|
-
}
|
|
1611
|
-
if (video) {
|
|
1612
|
-
try {
|
|
1613
|
-
videoPath = await renameVideo(await video.path(), runDirectory);
|
|
1614
|
-
} catch {
|
|
1615
|
-
videoPath = await findVideo(runDirectory);
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
try {
|
|
1619
|
-
if (browser) await browser.close();
|
|
1620
|
-
} catch {
|
|
1621
|
-
// Same: a closed browser is the desired end state either way.
|
|
1622
|
-
}
|
|
1623
|
-
page = null;
|
|
1624
|
-
context = null;
|
|
1625
|
-
browser = null;
|
|
1626
|
-
return { videoPath };
|
|
1627
|
-
}
|
|
1721
|
+
/** Idempotent: cancellation, crashes and finally share one finalization. */
|
|
1722
|
+
stop
|
|
1628
1723
|
};
|
|
1629
1724
|
}
|
|
1630
1725
|
|
|
@@ -1694,37 +1789,4 @@ export function attachNetworkFailures(entries, failures) {
|
|
|
1694
1789
|
});
|
|
1695
1790
|
}
|
|
1696
1791
|
|
|
1697
|
-
/**
|
|
1698
|
-
* Playwright names its recording with a random id. The upload allowlist and
|
|
1699
|
-
* the server's 90 day lifecycle rule are written against `session.webm`, so
|
|
1700
|
-
* the file is renamed rather than uploaded under whatever name it got.
|
|
1701
|
-
*
|
|
1702
|
-
* @param {string} source
|
|
1703
|
-
* @param {string} runDir
|
|
1704
|
-
* @returns {Promise<string>}
|
|
1705
|
-
*/
|
|
1706
|
-
async function renameVideo(source, runDir) {
|
|
1707
|
-
const target = path.join(runDir, 'session.webm');
|
|
1708
|
-
if (!source || path.resolve(source) === path.resolve(target)) return target;
|
|
1709
|
-
await rename(source, target);
|
|
1710
|
-
return target;
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
/**
|
|
1714
|
-
* @param {string} runDir
|
|
1715
|
-
* @returns {Promise<string|null>}
|
|
1716
|
-
*/
|
|
1717
|
-
async function findVideo(runDir) {
|
|
1718
|
-
try {
|
|
1719
|
-
const entries = await readdir(runDir);
|
|
1720
|
-
const webm = entries.find(entry => entry.endsWith('.webm'));
|
|
1721
|
-
if (!webm) return null;
|
|
1722
|
-
const source = path.join(runDir, webm);
|
|
1723
|
-
await stat(source);
|
|
1724
|
-
return await renameVideo(source, runDir);
|
|
1725
|
-
} catch {
|
|
1726
|
-
return null;
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
1792
|
export default createWebDriver;
|