@amg555/frameforge-mcp 2.3.2 → 2.3.3

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 (48) hide show
  1. package/.mcp.json +2 -4
  2. package/CHANGELOG.md +1 -1
  3. package/CLAUDE.md +104 -26
  4. package/README.md +16 -12
  5. package/SECURITY.md +1 -3
  6. package/package.json +7 -3
  7. package/plugins/video-pipeline/.claude-plugin/plugin.json +19 -0
  8. package/plugins/video-pipeline/skills/html-renderer/SKILL.md +292 -0
  9. package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +308 -0
  10. package/plugins/video-pipeline/skills/video-pipeline/assets/project/ANIMATION_GUIDE.md +257 -0
  11. package/plugins/video-pipeline/skills/video-pipeline/assets/project/STORYBOARD.md +77 -0
  12. package/plugins/video-pipeline/skills/video-pipeline/assets/project/index.html +80 -0
  13. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/diagram-author.md +43 -0
  14. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +56 -0
  15. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +42 -0
  16. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +140 -0
  17. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +870 -0
  18. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +138 -0
  19. package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +167 -0
  20. package/plugins/video-pipeline/skills/video-pipeline/references/providers.md +163 -0
  21. package/scripts/db-migrate.js +112 -0
  22. package/scripts/deep-validate.js +326 -0
  23. package/scripts/generate-initial-assets.js +342 -0
  24. package/scripts/html-renderer/new-project.mjs +408 -0
  25. package/scripts/html-renderer/transcribe.py +294 -0
  26. package/src/asset-library/README.md +2 -2
  27. package/src/asset-library/asset-library.js +1 -1
  28. package/src/cli/make.js +52 -71
  29. package/src/cli/setup-keys.js +15 -17
  30. package/src/fact-checker/review-gate.js +5 -1
  31. package/src/harness/bin/harness.mjs +324 -0
  32. package/src/harness/lib/browser.mjs +137 -0
  33. package/src/harness/lib/capture.mjs +161 -0
  34. package/src/harness/lib/encode.mjs +89 -0
  35. package/src/harness/lib/jobs.mjs +135 -0
  36. package/src/harness/lib/pixels.mjs +137 -0
  37. package/src/harness/lib/sheets.mjs +164 -0
  38. package/src/harness/lib/verify-purity.mjs +119 -0
  39. package/src/harness/test/fixtures/broken-scene.html +27 -0
  40. package/src/harness/test/fixtures/reference-scene.html +23 -0
  41. package/src/harness/test/fixtures/sample-script.json +18 -0
  42. package/src/mcp-server/server.js +285 -144
  43. package/src/pipeline/telemetry.js +1 -0
  44. package/src/utils/log-redaction.js +5 -3
  45. package/src/video-renderer/renderer.js +12 -4
  46. package/src/worker/server-render-free.js +2 -3
  47. package/src/workflows/assets.js +20 -43
  48. package/src/workflows/ingest.js +5 -5
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * harness.mjs — Code-Driven Animation Production Harness CLI
4
+ *
5
+ * Implements the 9-part video production harness spec:
6
+ * doctor — Verify Chrome path, GPU flags, and WebGL context
7
+ * storyboard — Validate STORYBOARD.md against the 4 gates
8
+ * verify — 3-point history-leak & purity gate (repeat, wander, cold-jump)
9
+ * sheet — Contact sheet of key timestamps
10
+ * strip — Full-rate motion strip of a time range
11
+ * crop — Full-res framing crop check
12
+ * pixels — Blind-spot frame analysis (blank, blown, corner-confined)
13
+ * frames — Parallel, atomic, resumable frame render
14
+ * encode — Master MP4 encoding with BT.709 tags and audio mux
15
+ * all — Complete gate-checked end-to-end production
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import { fileURLToPath } from 'url';
21
+ import { doctorBrowser, launchHarnessBrowser } from '../lib/browser.mjs';
22
+ import { openPageWithVirtualClock } from '../lib/capture.mjs';
23
+ import { verifyScenePurity } from '../lib/verify-purity.mjs';
24
+ import { renderSheet, renderStrip, renderCrop, renderStoryboard } from '../lib/sheets.mjs';
25
+ import { renderFrameJobs } from '../lib/jobs.mjs';
26
+ import { encodeFramesToMp4 } from '../lib/encode.mjs';
27
+ import { analyzeImageFile } from '../lib/pixels.mjs';
28
+ import { spawnSync } from 'child_process';
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+ const CWD = process.cwd();
32
+
33
+ // Argument parsing
34
+ const argv = process.argv.slice(2);
35
+ const command = argv[0] || 'doctor';
36
+ const flags = {};
37
+ const extras = [];
38
+
39
+ for (let i = 1; i < argv.length; i++) {
40
+ const arg = argv[i];
41
+ if (arg.startsWith('--')) {
42
+ const [k, v] = arg.slice(2).split('=');
43
+ flags[k] = v ?? true;
44
+ } else {
45
+ extras.push(arg);
46
+ }
47
+ }
48
+
49
+ function resolveProjectFile(filename, fallback) {
50
+ const inCwd = path.resolve(CWD, filename);
51
+ if (fs.existsSync(inCwd)) return inCwd;
52
+ if (fallback && fs.existsSync(fallback)) return fallback;
53
+ return inCwd;
54
+ }
55
+
56
+ const HTML_FILE = flags.html ? path.resolve(CWD, flags.html) : resolveProjectFile('index.html', resolveProjectFile('studio.html'));
57
+ const OUT_DIR = flags.outDir ? path.resolve(CWD, flags.outDir) : path.join(CWD, 'out');
58
+ const CHECK_DIR = path.join(OUT_DIR, 'check');
59
+ const FRAMES_DIR = path.join(OUT_DIR, 'frames');
60
+ const PURITY_LOG = path.join(CHECK_DIR, 'purity_report.json');
61
+
62
+ fs.mkdirSync(OUT_DIR, { recursive: true });
63
+ fs.mkdirSync(CHECK_DIR, { recursive: true });
64
+
65
+ async function cmdDoctor() {
66
+ console.log('🩺 FrameForge Harness — Doctor');
67
+ const res = await doctorBrowser({
68
+ softGl: flags['soft-gl'],
69
+ gpuAngle: flags['gpu-angle'],
70
+ chromePath: flags.chrome,
71
+ });
72
+
73
+ if (res.status === 'ok') {
74
+ console.log(` ✅ Chrome: ${res.chromePath}`);
75
+ console.log(` ✅ WebGL: ${res.webgl ? 'available' : 'unavailable'}`);
76
+ console.log(` ✅ Renderer: ${res.renderer}`);
77
+ console.log(` ✅ Vendor: ${res.vendor}`);
78
+ console.log(` Flags: ${res.flags.join(' ')}`);
79
+ } else {
80
+ console.error(` ❌ Doctor failed: ${res.error}`);
81
+ process.exit(1);
82
+ }
83
+ }
84
+
85
+ async function cmdStoryboard() {
86
+ const sbPath = flags.file ? path.resolve(CWD, flags.file) : resolveProjectFile('STORYBOARD.md');
87
+ console.log(`📋 Validating Storyboard: ${sbPath}`);
88
+
89
+ const validatorCandidates = [
90
+ path.join(CWD, 'scripts', 'storyboard-validator.mjs'),
91
+ path.resolve(__dirname, '../../../plugins/video-pipeline/skills/video-pipeline/assets/project/scripts/storyboard-validator.mjs'),
92
+ path.join(__dirname, 'storyboard-validator.mjs'),
93
+ ];
94
+ const validatorScript = validatorCandidates.find(p => fs.existsSync(p));
95
+ if (validatorScript) {
96
+ const res = spawnSync('node', [validatorScript, sbPath], { stdio: 'inherit' });
97
+ if (res.status !== 0) {
98
+ console.error('❌ Storyboard gate failed. Fix storyboard before proceeding to code.');
99
+ process.exit(1);
100
+ }
101
+ } else {
102
+ console.log(' ✅ Storyboard file present.');
103
+ }
104
+ }
105
+
106
+ async function cmdVerify() {
107
+ console.log(`🔬 Running 3-Point Purity Verification: ${HTML_FILE}`);
108
+ const { browser } = await launchHarnessBrowser({
109
+ softGl: flags['soft-gl'],
110
+ chromePath: flags.chrome,
111
+ });
112
+
113
+ let times = null;
114
+ if (flags.times) {
115
+ times = flags.times.split(',').map(Number).filter(n => !isNaN(n));
116
+ }
117
+
118
+ const duration = flags.duration ? parseFloat(flags.duration) : null;
119
+ const res = await verifyScenePurity({
120
+ browser,
121
+ htmlPath: HTML_FILE,
122
+ duration,
123
+ times,
124
+ width: 640,
125
+ height: 360,
126
+ });
127
+ await browser.close();
128
+
129
+ fs.writeFileSync(PURITY_LOG, JSON.stringify(res, null, 2), 'utf8');
130
+
131
+ console.log(`\n Tested ${res.timesTested} sample points.`);
132
+ for (const r of res.results) {
133
+ const icon = r.ok ? '✅' : '❌';
134
+ console.log(` ${icon} t=${r.time}s hash=${r.a}${r.failure ? ' — ' + r.failure : ''}`);
135
+ }
136
+
137
+ if (res.passed) {
138
+ console.log(`\n ✅ PURITY GATE PASSED — frames_sha256: ${res.framesSha256}`);
139
+ console.log(` Report: ${PURITY_LOG}`);
140
+ } else {
141
+ console.error('\n ❌ PURITY GATE FAILED — non-determinism or history leak detected!');
142
+ process.exit(1);
143
+ }
144
+ }
145
+
146
+ async function cmdSheet() {
147
+ const { browser } = await launchHarnessBrowser();
148
+ const page = await openPageWithVirtualClock({ browser, htmlPath: HTML_FILE });
149
+ const outPath = flags.out ? path.resolve(CWD, flags.out) : path.join(CHECK_DIR, 'sheet.jpg');
150
+
151
+ let times = [0.1, 0.5, 1.0, 1.5, 2.0];
152
+ if (flags.times) {
153
+ times = flags.times.split(',').map(Number).filter(n => !isNaN(n));
154
+ }
155
+
156
+ console.log(`🖼 Generating Contact Sheet (${times.length} frames) → ${outPath}`);
157
+ await renderSheet({
158
+ page,
159
+ times,
160
+ cols: parseInt(flags.cols || '5', 10),
161
+ thumbWidth: parseInt(flags.w || '320', 10),
162
+ outPath,
163
+ });
164
+ await browser.close();
165
+ console.log(` ✅ Sheet ready: ${outPath}`);
166
+ }
167
+
168
+ async function cmdStrip() {
169
+ const range = (flags.range || extras[0] || '0.0:1.0').split(':').map(Number);
170
+ const start = range[0] || 0.0;
171
+ const end = range[1] || 1.0;
172
+ const outPath = flags.out ? path.resolve(CWD, flags.out) : path.join(CHECK_DIR, `strip_${start}_${end}s.jpg`);
173
+
174
+ const { browser } = await launchHarnessBrowser();
175
+ const page = await openPageWithVirtualClock({ browser, htmlPath: HTML_FILE });
176
+
177
+ console.log(`🎞 Generating Motion Strip [${start}s → ${end}s] → ${outPath}`);
178
+ await renderStrip({
179
+ page,
180
+ start,
181
+ end,
182
+ fps: parseInt(flags.fps || '30', 10),
183
+ thumbWidth: parseInt(flags.w || '320', 10),
184
+ outPath,
185
+ });
186
+ await browser.close();
187
+ console.log(` ✅ Strip ready: ${outPath}`);
188
+ }
189
+
190
+ async function cmdCrop() {
191
+ const time = parseFloat(flags.time || extras[0] || '1.0');
192
+ const cropStr = flags.at || flags.crop || '9:16';
193
+ let cropParam = cropStr;
194
+ if (cropStr.includes(',')) {
195
+ cropParam = cropStr.split(',').map(Number);
196
+ }
197
+ const outPath = flags.out ? path.resolve(CWD, flags.out) : path.join(CHECK_DIR, `crop_${time}s.jpg`);
198
+
199
+ const { browser } = await launchHarnessBrowser();
200
+ const page = await openPageWithVirtualClock({ browser, htmlPath: HTML_FILE });
201
+
202
+ console.log(`✂ Generating Crop Check (at ${time}s, framing=${cropStr}) → ${outPath}`);
203
+ await renderCrop({
204
+ page,
205
+ time,
206
+ crop: cropParam,
207
+ outPath,
208
+ });
209
+ await browser.close();
210
+ console.log(` ✅ Crop ready: ${outPath}`);
211
+ }
212
+
213
+ async function cmdFrames() {
214
+ const fps = parseInt(flags.fps || '30', 10);
215
+ const workers = parseInt(flags.workers || '4', 10);
216
+ const duration = flags.duration ? parseFloat(flags.duration) : 2.0;
217
+ const totalFrames = Math.round(duration * fps);
218
+
219
+ console.log(`📦 Rendering Frame Range [0..${totalFrames - 1}] (${duration}s @ ${fps}fps, workers=${workers})`);
220
+ const { browser } = await launchHarnessBrowser();
221
+
222
+ const res = await renderFrameJobs({
223
+ browser,
224
+ htmlPath: HTML_FILE,
225
+ framesDir: FRAMES_DIR,
226
+ first: 0,
227
+ last: totalFrames - 1,
228
+ fps,
229
+ workers,
230
+ width: parseInt(flags.width || '1920', 10),
231
+ height: parseInt(flags.height || '1080', 10),
232
+ format: flags.format || 'jpg',
233
+ onProgress: (done, total) => {
234
+ process.stdout.write(`\r Frames: ${done}/${total} (${Math.round((done / total) * 100)}%)`);
235
+ },
236
+ });
237
+ process.stdout.write('\n');
238
+ await browser.close();
239
+
240
+ console.log(` ✅ ${res.rendered} rendered, ${res.skipped} skipped (resumed). Total: ${res.total}`);
241
+ }
242
+
243
+ async function cmdEncode() {
244
+ // Purity report gate: refuse encode if purity report does not exist or failed
245
+ if (!flags['skip-verify']) {
246
+ if (!fs.existsSync(PURITY_LOG)) {
247
+ console.warn('⚠️ No purity report found. Running verify gate first...');
248
+ await cmdVerify();
249
+ } else {
250
+ const rep = JSON.parse(fs.readFileSync(PURITY_LOG, 'utf8'));
251
+ if (!rep.passed) {
252
+ console.error('❌ ENCODE REFUSED: Purity report in out/check/purity_report.json has FAIL status.');
253
+ process.exit(1);
254
+ }
255
+ }
256
+ }
257
+
258
+ const fps = parseInt(flags.fps || '30', 10);
259
+ const duration = parseFloat(flags.duration || '2.0');
260
+ const ext = flags.format === 'png' ? 'png' : 'jpg';
261
+ const outPath = flags.out ? path.resolve(CWD, flags.out) : path.join(OUT_DIR, 'film.mp4');
262
+ const framesPattern = path.join(FRAMES_DIR, `f%06d.${ext}`);
263
+ const audioPath = flags.audio ? path.resolve(CWD, flags.audio) : null;
264
+
265
+ console.log(`🎬 Encoding Master MP4 → ${outPath}`);
266
+ const encRes = await encodeFramesToMp4({
267
+ framesPattern,
268
+ duration,
269
+ fps,
270
+ crf: parseInt(flags.crf || '17', 10),
271
+ audioPath,
272
+ outPath,
273
+ });
274
+
275
+ console.log(` ✅ Master encoded successfully (${(encRes.sizeBytes / 1024 / 1024).toFixed(2)} MB)`);
276
+ }
277
+
278
+ async function cmdPixels() {
279
+ const target = extras[0] || path.join(CHECK_DIR, 'sheet.jpg');
280
+ console.log(`🔍 Inspecting Pixels: ${target}`);
281
+ const analysis = analyzeImageFile(target);
282
+ console.log(` Mean Color: RGB(${analysis.meanColor.join(', ')})`);
283
+ console.log(` Bright Pixel Share: ${(analysis.brightPixelShare * 100).toFixed(2)}%`);
284
+ console.log(` BBox: ${analysis.bbox ? analysis.bbox.join(', ') : 'none'}`);
285
+ console.log(` Blank: ${analysis.isBlank} | Blown: ${analysis.isBlown} | Corner-confined: ${analysis.isCornerConfined}`);
286
+ if (analysis.flags.length > 0) {
287
+ console.warn(` ⚠️ Visual Flags Triggered: ${analysis.flags.join(', ')}`);
288
+ } else {
289
+ console.log(' ✅ No visual anomalies detected.');
290
+ }
291
+ }
292
+
293
+ async function cmdAll() {
294
+ console.log('🚀 Running Complete Production Pipeline: Storyboard → Verify → Frames → Encode → Inspect');
295
+ await cmdDoctor();
296
+ await cmdStoryboard();
297
+ await cmdSheet();
298
+ await cmdVerify();
299
+ await cmdFrames();
300
+ await cmdEncode();
301
+ console.log('\n✨ Production complete! Output ready at out/film.mp4');
302
+ }
303
+
304
+ try {
305
+ switch (command) {
306
+ case 'doctor': await cmdDoctor(); break;
307
+ case 'storyboard': await cmdStoryboard(); break;
308
+ case 'verify': await cmdVerify(); break;
309
+ case 'sheet': await cmdSheet(); break;
310
+ case 'strip': await cmdStrip(); break;
311
+ case 'crop': await cmdCrop(); break;
312
+ case 'frames': await cmdFrames(); break;
313
+ case 'encode': await cmdEncode(); break;
314
+ case 'pixels': await cmdPixels(); break;
315
+ case 'all': await cmdAll(); break;
316
+ default:
317
+ console.log(`Usage: harness <doctor|storyboard|verify|sheet|strip|crop|frames|encode|pixels|all> [options]`);
318
+ break;
319
+ }
320
+ } catch (err) {
321
+ console.error(`\n❌ Harness error: ${err.message}`);
322
+ process.exit(1);
323
+ }
324
+
@@ -0,0 +1,137 @@
1
+ /**
2
+ * browser.mjs — Chrome Resolution & Platform Launch Flags
3
+ *
4
+ * Resolves local Google Chrome / Chromium executable and applies platform-specific
5
+ * WebGL and sandbox flags (Metal on macOS, D3D11 on Win32, ANGLE on Linux, SwiftShader fallback).
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+
11
+ export function findChromePath(customPath = null) {
12
+ const candidates = [
13
+ customPath,
14
+ process.env.CHROME_PATH,
15
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
16
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
17
+ process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Google/Chrome/Application/chrome.exe') : null,
18
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
19
+ '/usr/bin/google-chrome',
20
+ '/usr/bin/google-chrome-stable',
21
+ '/usr/bin/chromium',
22
+ '/usr/bin/chromium-browser',
23
+ '/snap/bin/chromium',
24
+ ].filter(Boolean);
25
+
26
+ for (const candidate of candidates) {
27
+ try {
28
+ if (fs.existsSync(candidate)) {
29
+ return candidate;
30
+ }
31
+ } catch (_) {}
32
+ }
33
+
34
+ return null;
35
+ }
36
+
37
+ export function getBrowserLaunchFlags({ softGl = false, gpuAngle = null } = {}) {
38
+ const isWin = process.platform === 'win32';
39
+ const isMac = process.platform === 'darwin';
40
+ const isLinux = process.platform === 'linux';
41
+
42
+ const ANGLE = {
43
+ d3d11: ['--use-angle=d3d11'],
44
+ metal: ['--use-angle=metal'],
45
+ gl: ['--use-angle=gl'],
46
+ vulkan: ['--use-angle=vulkan'],
47
+ swiftshader: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
48
+ };
49
+
50
+ const gpu = softGl
51
+ ? ANGLE.swiftshader
52
+ : gpuAngle && ANGLE[gpuAngle]
53
+ ? ANGLE[gpuAngle]
54
+ : isWin
55
+ ? ANGLE.d3d11
56
+ : isMac
57
+ ? ANGLE.metal
58
+ : ['--use-gl=angle'];
59
+
60
+ const sandbox = isLinux ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
61
+
62
+ return [
63
+ ...sandbox,
64
+ '--allow-file-access-from-files',
65
+ '--disable-web-security',
66
+ '--ignore-gpu-blocklist',
67
+ ...gpu,
68
+ '--enable-gpu-rasterization',
69
+ '--window-size=1920,1080',
70
+ '--disable-renderer-backgrounding',
71
+ '--disable-background-timer-throttling',
72
+ '--disable-gpu-watchdog',
73
+ ];
74
+ }
75
+
76
+ export async function launchHarnessBrowser(opts = {}) {
77
+ let chromium;
78
+ try {
79
+ ({ chromium } = await import('playwright'));
80
+ } catch (err) {
81
+ throw new Error('Playwright is required. Run: npm install playwright && npx playwright install chromium');
82
+ }
83
+
84
+ const execPath = findChromePath(opts.chromePath);
85
+ const args = getBrowserLaunchFlags(opts);
86
+
87
+ const launchOpts = {
88
+ headless: opts.headless !== false,
89
+ args: [...args, ...(opts.extraArgs || [])],
90
+ };
91
+
92
+ if (execPath) {
93
+ launchOpts.executablePath = execPath;
94
+ }
95
+
96
+ const browser = await chromium.launch(launchOpts);
97
+ return { browser, executablePath: execPath || 'bundled-chromium' };
98
+ }
99
+
100
+ export async function doctorBrowser(opts = {}) {
101
+ const resolvedPath = findChromePath(opts.chromePath);
102
+ const launchFlags = getBrowserLaunchFlags(opts);
103
+
104
+ try {
105
+ const { browser, executablePath } = await launchHarnessBrowser(opts);
106
+ const page = await browser.newPage();
107
+ const info = await page.evaluate(() => {
108
+ const canvas = document.createElement('canvas');
109
+ const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
110
+ if (!gl) return { webgl: false, renderer: 'none', vendor: 'none' };
111
+ const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
112
+ return {
113
+ webgl: true,
114
+ vendor: debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
115
+ renderer: debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
116
+ };
117
+ });
118
+ await browser.close();
119
+
120
+ return {
121
+ status: 'ok',
122
+ chromePath: executablePath,
123
+ webgl: info.webgl,
124
+ renderer: info.renderer,
125
+ vendor: info.vendor,
126
+ flags: launchFlags,
127
+ };
128
+ } catch (err) {
129
+ return {
130
+ status: 'error',
131
+ chromePath: resolvedPath,
132
+ error: err.message,
133
+ flags: launchFlags,
134
+ };
135
+ }
136
+ }
137
+
@@ -0,0 +1,161 @@
1
+ /**
2
+ * capture.mjs — Virtual Clock Injection & Deterministic Frame Stepper
3
+ *
4
+ * Replaces Date.now, performance.now, requestAnimationFrame, setTimeout/setInterval,
5
+ * and CSS animations with a virtual clock. Supports both canvas/WebGL renderAt(t)
6
+ * and DOM/canvas seek(t).
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { pathToFileURL } from 'url';
12
+
13
+ export const VIRTUAL_CLOCK_INJECTION = `
14
+ (function () {
15
+ let _now = 0;
16
+ let _rafQueue = new Map();
17
+ let _rafId = 0;
18
+ let _timerQueue = [];
19
+ let _timerId = 0;
20
+ const _startDate = Date.now();
21
+
22
+ window.__playwright = true;
23
+
24
+ // 1. Deterministic clocks
25
+ performance.now = () => _now;
26
+ Date.now = () => _startDate + _now;
27
+
28
+ // 2. Deterministic requestAnimationFrame
29
+ window.requestAnimationFrame = (cb) => {
30
+ const id = ++_rafId;
31
+ _rafQueue.set(id, cb);
32
+ return id;
33
+ };
34
+ window.cancelAnimationFrame = (id) => {
35
+ _rafQueue.delete(id);
36
+ };
37
+
38
+ // 3. Virtual timer queue
39
+ window.setTimeout = (fn, delay = 0, ...args) => {
40
+ const id = ++_timerId;
41
+ _timerQueue.push({ id, runAt: _now + Math.max(0, delay), fn, args, repeat: 0 });
42
+ _timerQueue.sort((a, b) => a.runAt - b.runAt);
43
+ return id;
44
+ };
45
+ window.clearTimeout = (id) => {
46
+ const idx = _timerQueue.findIndex(t => t.id === id);
47
+ if (idx !== -1) _timerQueue.splice(idx, 1);
48
+ };
49
+ window.setInterval = (fn, interval = 0, ...args) => {
50
+ const id = ++_timerId;
51
+ const step = Math.max(16, interval);
52
+ _timerQueue.push({ id, runAt: _now + step, fn, args, repeat: step });
53
+ _timerQueue.sort((a, b) => a.runAt - b.runAt);
54
+ return id;
55
+ };
56
+ window.clearInterval = (id) => {
57
+ const idx = _timerQueue.findIndex(t => t.id === id);
58
+ if (idx !== -1) _timerQueue.splice(idx, 1);
59
+ };
60
+
61
+ // 4. Deterministic Seeded PRNG fallback for unseeded Math.random()
62
+ let _seed = 0x2F6E2B1;
63
+ window.Math.random = () => {
64
+ _seed = (_seed + 0x6D2B79F5) | 0;
65
+ let t = Math.imul(_seed ^ (_seed >>> 15), 1 | _seed);
66
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
67
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
68
+ };
69
+
70
+ // 5. Virtual time advance
71
+ window.__advance = (ms) => {
72
+ _now += ms;
73
+ while (_timerQueue.length > 0 && _timerQueue[0].runAt <= _now) {
74
+ const item = _timerQueue.shift();
75
+ try { item.fn(...item.args); } catch (_) {}
76
+ if (item.repeat > 0) {
77
+ item.runAt = _now + item.repeat;
78
+ _timerQueue.push(item);
79
+ _timerQueue.sort((a, b) => a.runAt - b.runAt);
80
+ }
81
+ }
82
+ const currentRafs = Array.from(_rafQueue.values());
83
+ _rafQueue.clear();
84
+ currentRafs.forEach(cb => { try { cb(_now); } catch (_) {} });
85
+
86
+ try {
87
+ document.getAnimations().forEach(a => {
88
+ if (a.playState !== 'paused') a.pause();
89
+ a.currentTime = _now;
90
+ });
91
+ } catch (_) {}
92
+ };
93
+
94
+ // 6. Absolute virtual seek
95
+ window.__seek = async (t) => {
96
+ _now = t * 1000;
97
+ if (window.renderAt) {
98
+ await window.renderAt(t);
99
+ } else if (window.seek) {
100
+ await window.seek(t);
101
+ }
102
+ while (_timerQueue.length > 0 && _timerQueue[0].runAt <= _now) {
103
+ const item = _timerQueue.shift();
104
+ try { item.fn(...item.args); } catch (_) {}
105
+ }
106
+ if (document.fonts && document.fonts.ready) {
107
+ await document.fonts.ready;
108
+ }
109
+ };
110
+ })();
111
+ `;
112
+
113
+ export async function openPageWithVirtualClock(browserOrOpts, htmlPathArg, optsArg = {}) {
114
+ let browser, htmlPath, width, height;
115
+ if (browserOrOpts && typeof browserOrOpts === 'object' && browserOrOpts.browser) {
116
+ browser = browserOrOpts.browser;
117
+ htmlPath = browserOrOpts.htmlPath;
118
+ width = browserOrOpts.width || 1920;
119
+ height = browserOrOpts.height || 1080;
120
+ } else {
121
+ browser = browserOrOpts;
122
+ htmlPath = htmlPathArg;
123
+ width = optsArg.width || 1920;
124
+ height = optsArg.height || 1080;
125
+ }
126
+ const page = await browser.newPage();
127
+ await page.addInitScript(VIRTUAL_CLOCK_INJECTION);
128
+
129
+ const fileUrl = pathToFileURL(path.resolve(htmlPath)).href;
130
+ await page.goto(fileUrl, { waitUntil: 'load' });
131
+
132
+ // Await fonts before frame 0
133
+ await page.evaluate(async () => {
134
+ if (document.fonts && document.fonts.ready) {
135
+ await document.fonts.ready;
136
+ }
137
+ });
138
+
139
+ await page.setViewportSize({ width, height });
140
+ return page;
141
+ }
142
+
143
+ export async function captureFrame(page, t, { type = 'image/png', quality = 0.94 } = {}) {
144
+ const dataUrl = await page.evaluate(async ({ time, mime, q }) => {
145
+ await window.__seek(time);
146
+ if (window.renderAt) {
147
+ return await window.renderAt(time, mime, q);
148
+ }
149
+ return null;
150
+ }, { time: t, mime: type, q: quality });
151
+
152
+ if (typeof dataUrl === 'string' && dataUrl.includes(',')) {
153
+ return Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
154
+ }
155
+
156
+ const screenshotType = type === 'image/jpeg' ? 'jpeg' : 'png';
157
+ return page.screenshot({
158
+ type: screenshotType,
159
+ quality: screenshotType === 'jpeg' ? Math.round(quality * 100) : undefined,
160
+ });
161
+ }