@arclabs561/ai-visual-test 0.5.1

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 (93) hide show
  1. package/.secretsignore.example +20 -0
  2. package/CHANGELOG.md +360 -0
  3. package/CONTRIBUTING.md +63 -0
  4. package/DEPLOYMENT.md +80 -0
  5. package/LICENSE +22 -0
  6. package/README.md +142 -0
  7. package/SECURITY.md +108 -0
  8. package/api/health.js +34 -0
  9. package/api/validate.js +252 -0
  10. package/index.d.ts +1221 -0
  11. package/package.json +112 -0
  12. package/public/index.html +149 -0
  13. package/src/batch-optimizer.mjs +451 -0
  14. package/src/bias-detector.mjs +370 -0
  15. package/src/bias-mitigation.mjs +233 -0
  16. package/src/cache.mjs +433 -0
  17. package/src/config.mjs +268 -0
  18. package/src/constants.mjs +80 -0
  19. package/src/context-compressor.mjs +350 -0
  20. package/src/convenience.mjs +617 -0
  21. package/src/cost-tracker.mjs +257 -0
  22. package/src/cross-modal-consistency.mjs +170 -0
  23. package/src/data-extractor.mjs +232 -0
  24. package/src/dynamic-few-shot.mjs +140 -0
  25. package/src/dynamic-prompts.mjs +361 -0
  26. package/src/ensemble/index.mjs +53 -0
  27. package/src/ensemble-judge.mjs +366 -0
  28. package/src/error-handler.mjs +67 -0
  29. package/src/errors.mjs +167 -0
  30. package/src/experience-propagation.mjs +128 -0
  31. package/src/experience-tracer.mjs +487 -0
  32. package/src/explanation-manager.mjs +299 -0
  33. package/src/feedback-aggregator.mjs +248 -0
  34. package/src/game-goal-prompts.mjs +478 -0
  35. package/src/game-player.mjs +548 -0
  36. package/src/hallucination-detector.mjs +155 -0
  37. package/src/helpers/playwright.mjs +80 -0
  38. package/src/human-validation-manager.mjs +516 -0
  39. package/src/index.mjs +364 -0
  40. package/src/judge.mjs +929 -0
  41. package/src/latency-aware-batch-optimizer.mjs +192 -0
  42. package/src/load-env.mjs +159 -0
  43. package/src/logger.mjs +55 -0
  44. package/src/metrics.mjs +187 -0
  45. package/src/model-tier-selector.mjs +221 -0
  46. package/src/multi-modal/index.mjs +36 -0
  47. package/src/multi-modal-fusion.mjs +190 -0
  48. package/src/multi-modal.mjs +524 -0
  49. package/src/natural-language-specs.mjs +1071 -0
  50. package/src/pair-comparison.mjs +277 -0
  51. package/src/persona/index.mjs +42 -0
  52. package/src/persona-enhanced.mjs +200 -0
  53. package/src/persona-experience.mjs +572 -0
  54. package/src/position-counterbalance.mjs +140 -0
  55. package/src/prompt-composer.mjs +375 -0
  56. package/src/render-change-detector.mjs +583 -0
  57. package/src/research-enhanced-validation.mjs +436 -0
  58. package/src/retry.mjs +152 -0
  59. package/src/rubrics.mjs +231 -0
  60. package/src/score-tracker.mjs +277 -0
  61. package/src/smart-validator.mjs +447 -0
  62. package/src/spec-config.mjs +106 -0
  63. package/src/spec-templates.mjs +347 -0
  64. package/src/specs/index.mjs +38 -0
  65. package/src/temporal/index.mjs +102 -0
  66. package/src/temporal-adaptive.mjs +163 -0
  67. package/src/temporal-batch-optimizer.mjs +222 -0
  68. package/src/temporal-constants.mjs +69 -0
  69. package/src/temporal-context.mjs +49 -0
  70. package/src/temporal-decision-manager.mjs +271 -0
  71. package/src/temporal-decision.mjs +669 -0
  72. package/src/temporal-errors.mjs +58 -0
  73. package/src/temporal-note-pruner.mjs +173 -0
  74. package/src/temporal-preprocessor.mjs +543 -0
  75. package/src/temporal-prompt-formatter.mjs +219 -0
  76. package/src/temporal-validation.mjs +159 -0
  77. package/src/temporal.mjs +415 -0
  78. package/src/type-guards.mjs +311 -0
  79. package/src/uncertainty-reducer.mjs +470 -0
  80. package/src/utils/index.mjs +175 -0
  81. package/src/validation-framework.mjs +321 -0
  82. package/src/validation-result-normalizer.mjs +64 -0
  83. package/src/validation.mjs +243 -0
  84. package/src/validators/accessibility-programmatic.mjs +345 -0
  85. package/src/validators/accessibility-validator.mjs +223 -0
  86. package/src/validators/batch-validator.mjs +143 -0
  87. package/src/validators/hybrid-validator.mjs +268 -0
  88. package/src/validators/index.mjs +34 -0
  89. package/src/validators/prompt-builder.mjs +218 -0
  90. package/src/validators/rubric.mjs +85 -0
  91. package/src/validators/state-programmatic.mjs +260 -0
  92. package/src/validators/state-validator.mjs +291 -0
  93. package/vercel.json +27 -0
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Render Change Detector
3
+ *
4
+ * Detects when page content actually renders/changes and triggers screenshots.
5
+ * Uses MutationObserver, ResizeObserver, requestAnimationFrame, and visual diff detection.
6
+ *
7
+ * Research: Visual event detection improves temporal understanding (VETL, WALT)
8
+ */
9
+
10
+ import { warn, log } from './logger.mjs';
11
+ import { existsSync, statSync } from 'fs';
12
+
13
+ /**
14
+ * Detect render changes using MutationObserver + ResizeObserver + requestAnimationFrame
15
+ *
16
+ * Enhanced to detect:
17
+ * - DOM mutations (MutationObserver)
18
+ * - Layout changes (ResizeObserver)
19
+ * - Visual updates (requestAnimationFrame polling for CSS animations)
20
+ *
21
+ * @param {any} page - Playwright page object
22
+ * @param {Function} onChange - Callback when change detected
23
+ * @param {Object} options - Detection options
24
+ * @returns {Promise<Function>} Cleanup function
25
+ */
26
+ export async function detectRenderChanges(page, onChange, options = {}) {
27
+ const {
28
+ subtree = true,
29
+ childList = true,
30
+ attributes = true,
31
+ characterData = true,
32
+ attributeFilter = null,
33
+ pollInterval = 100, // Check every 100ms
34
+ detectCSSAnimations = true, // Also detect CSS animations via RAF polling
35
+ detectLayoutChanges = true // Use ResizeObserver for layout changes
36
+ } = options;
37
+
38
+ let observer = null;
39
+ let resizeObserver = null;
40
+ let rafId = null;
41
+ let changeCount = 0;
42
+ const changeHistory = [];
43
+ const maxHistory = 100;
44
+ let lastRAFTime = 0;
45
+ let rafChangeCount = 0;
46
+
47
+ try {
48
+ // Set up MutationObserver in page context
49
+ await page.evaluate(({ subtree, childList, attributes, characterData, attributeFilter, detectLayoutChanges }) => {
50
+ // MutationObserver for DOM changes
51
+ window.__renderChangeObserver = new MutationObserver((mutations) => {
52
+ window.__renderChangeCount = (window.__renderChangeCount || 0) + 1;
53
+ window.__renderChangeTime = Date.now();
54
+ window.__renderChangeMutations = mutations.length;
55
+ });
56
+
57
+ const config = {
58
+ subtree,
59
+ childList,
60
+ attributes,
61
+ characterData
62
+ };
63
+
64
+ if (attributeFilter) {
65
+ config.attributeFilter = attributeFilter;
66
+ }
67
+
68
+ window.__renderChangeObserver.observe(document.body, config);
69
+
70
+ // ResizeObserver for layout changes (if enabled)
71
+ if (detectLayoutChanges) {
72
+ window.__renderResizeObserver = new ResizeObserver((entries) => {
73
+ window.__renderResizeCount = (window.__renderResizeCount || 0) + 1;
74
+ window.__renderResizeTime = Date.now();
75
+ window.__renderResizeEntries = entries.length;
76
+ });
77
+
78
+ window.__renderResizeObserver.observe(document.body);
79
+ }
80
+
81
+ // requestAnimationFrame polling for CSS animations (if enabled)
82
+ if (window.__detectCSSAnimations) {
83
+ let lastFrameTime = performance.now();
84
+ let frameCount = 0;
85
+
86
+ function rafLoop() {
87
+ const currentTime = performance.now();
88
+ const deltaTime = currentTime - lastFrameTime;
89
+
90
+ // If frame time is consistent (likely animation), count as change
91
+ // CSS animations typically run at 60fps (16.67ms per frame)
92
+ if (deltaTime > 0 && deltaTime < 20) { // 0-20ms = likely animation frame
93
+ frameCount++;
94
+ if (frameCount % 10 === 0) { // Every 10 frames = ~166ms
95
+ window.__renderRAFCount = (window.__renderRAFCount || 0) + 1;
96
+ window.__renderRAFTime = Date.now();
97
+ }
98
+ } else {
99
+ frameCount = 0; // Reset if not consistent
100
+ }
101
+
102
+ lastFrameTime = currentTime;
103
+ window.__renderRAFId = requestAnimationFrame(rafLoop);
104
+ }
105
+
106
+ window.__renderRAFId = requestAnimationFrame(rafLoop);
107
+ }
108
+
109
+ // Initialize counters
110
+ window.__renderChangeCount = 0;
111
+ window.__renderChangeTime = 0;
112
+ window.__renderChangeMutations = 0;
113
+ window.__renderResizeCount = 0;
114
+ window.__renderResizeTime = 0;
115
+ window.__renderResizeEntries = 0;
116
+ window.__renderRAFCount = 0;
117
+ window.__renderRAFTime = 0;
118
+ }, { subtree, childList, attributes, characterData, attributeFilter, detectLayoutChanges });
119
+
120
+ // Enable CSS animation detection if requested
121
+ if (detectCSSAnimations) {
122
+ await page.evaluate(() => {
123
+ window.__detectCSSAnimations = true;
124
+ });
125
+ }
126
+
127
+ // Poll for changes (Playwright doesn't support direct MutationObserver callbacks)
128
+ const pollId = setInterval(async () => {
129
+ try {
130
+ const changeInfo = await page.evaluate(() => {
131
+ const info = {
132
+ mutations: {
133
+ count: window.__renderChangeCount || 0,
134
+ time: window.__renderChangeTime || 0,
135
+ mutations: window.__renderChangeMutations || 0
136
+ },
137
+ resize: {
138
+ count: window.__renderResizeCount || 0,
139
+ time: window.__renderResizeTime || 0,
140
+ entries: window.__renderResizeEntries || 0
141
+ },
142
+ raf: {
143
+ count: window.__renderRAFCount || 0,
144
+ time: window.__renderRAFTime || 0
145
+ }
146
+ };
147
+
148
+ // Reset counters
149
+ window.__renderChangeCount = 0;
150
+ window.__renderChangeTime = 0;
151
+ window.__renderChangeMutations = 0;
152
+ window.__renderResizeCount = 0;
153
+ window.__renderResizeTime = 0;
154
+ window.__renderResizeEntries = 0;
155
+ window.__renderRAFCount = 0;
156
+ window.__renderRAFTime = 0;
157
+
158
+ return info;
159
+ });
160
+
161
+ const totalChanges = changeInfo.mutations.count + changeInfo.resize.count + changeInfo.raf.count;
162
+
163
+ if (totalChanges > 0) {
164
+ changeCount += totalChanges;
165
+ changeHistory.push({
166
+ timestamp: changeInfo.mutations.time || changeInfo.resize.time || changeInfo.raf.time || Date.now(),
167
+ mutations: changeInfo.mutations.mutations,
168
+ resizeEntries: changeInfo.resize.entries,
169
+ rafFrames: changeInfo.raf.count,
170
+ count: totalChanges,
171
+ types: {
172
+ dom: changeInfo.mutations.count > 0,
173
+ layout: changeInfo.resize.count > 0,
174
+ visual: changeInfo.raf.count > 0
175
+ }
176
+ });
177
+
178
+ // Keep history limited
179
+ if (changeHistory.length > maxHistory) {
180
+ changeHistory.shift();
181
+ }
182
+
183
+ // Call onChange callback
184
+ if (onChange) {
185
+ await onChange({
186
+ count: totalChanges,
187
+ mutations: changeInfo.mutations.mutations,
188
+ resizeEntries: changeInfo.resize.entries,
189
+ rafFrames: changeInfo.raf.count,
190
+ timestamp: changeInfo.mutations.time || changeInfo.resize.time || changeInfo.raf.time || Date.now(),
191
+ totalChanges: changeCount,
192
+ types: {
193
+ dom: changeInfo.mutations.count > 0,
194
+ layout: changeInfo.resize.count > 0,
195
+ visual: changeInfo.raf.count > 0
196
+ }
197
+ });
198
+ }
199
+ }
200
+ } catch (error) {
201
+ warn(`[Render Change] Poll error: ${error.message}`);
202
+ }
203
+ }, pollInterval);
204
+
205
+ // Return cleanup function
206
+ return async () => {
207
+ clearInterval(pollId);
208
+ try {
209
+ await page.evaluate(() => {
210
+ if (window.__renderChangeObserver) {
211
+ window.__renderChangeObserver.disconnect();
212
+ delete window.__renderChangeObserver;
213
+ }
214
+ if (window.__renderResizeObserver) {
215
+ window.__renderResizeObserver.disconnect();
216
+ delete window.__renderResizeObserver;
217
+ }
218
+ if (window.__renderRAFId) {
219
+ cancelAnimationFrame(window.__renderRAFId);
220
+ delete window.__renderRAFId;
221
+ }
222
+ delete window.__renderChangeCount;
223
+ delete window.__renderChangeTime;
224
+ delete window.__renderChangeMutations;
225
+ delete window.__renderResizeCount;
226
+ delete window.__renderResizeTime;
227
+ delete window.__renderResizeEntries;
228
+ delete window.__renderRAFCount;
229
+ delete window.__renderRAFTime;
230
+ delete window.__detectCSSAnimations;
231
+ });
232
+ } catch (error) {
233
+ warn(`[Render Change] Cleanup error: ${error.message}`);
234
+ }
235
+ };
236
+ } catch (error) {
237
+ warn(`[Render Change] Setup error: ${error.message}`);
238
+ return async () => {}; // Return no-op cleanup
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Calculate optimal frame rate based on change rate
244
+ *
245
+ * @param {Array} changeHistory - History of changes
246
+ * @param {Object} options - Calculation options
247
+ * @returns {number} Optimal FPS
248
+ */
249
+ export function calculateOptimalFPS(changeHistory, options = {}) {
250
+ const {
251
+ minFPS = 1,
252
+ maxFPS = 60,
253
+ targetChangeInterval = 100 // Target: capture every 100ms of changes
254
+ } = options;
255
+
256
+ if (changeHistory.length < 2) {
257
+ return minFPS;
258
+ }
259
+
260
+ // Calculate average time between changes
261
+ const intervals = [];
262
+ for (let i = 1; i < changeHistory.length; i++) {
263
+ const interval = changeHistory[i].timestamp - changeHistory[i - 1].timestamp;
264
+ if (interval > 0) {
265
+ intervals.push(interval);
266
+ }
267
+ }
268
+
269
+ if (intervals.length === 0) {
270
+ return minFPS;
271
+ }
272
+
273
+ const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
274
+
275
+ // Calculate FPS based on average interval
276
+ // If changes happen every 16ms (60fps), we need 60fps capture
277
+ // If changes happen every 1000ms (1fps), we need 1fps capture
278
+ const optimalFPS = Math.max(minFPS, Math.min(maxFPS, 1000 / avgInterval));
279
+
280
+ return Math.round(optimalFPS);
281
+ }
282
+
283
+ /**
284
+ * Compare two screenshots to detect visual changes
285
+ *
286
+ * Enhanced with pixel-level comparison using lightweight method.
287
+ *
288
+ * @param {string} screenshot1Path - Path to first screenshot
289
+ * @param {string} screenshot2Path - Path to second screenshot
290
+ * @param {Object} options - Comparison options
291
+ * @returns {Promise<{ changed: boolean, diff: number, regions: Array }>} Comparison result
292
+ */
293
+ export async function detectVisualChanges(screenshot1Path, screenshot2Path, options = {}) {
294
+ const {
295
+ threshold = 0.01, // 1% difference threshold
296
+ useImageDiff = false, // Use image diff library if available
297
+ usePixelComparison = true // Use lightweight pixel comparison
298
+ } = options;
299
+
300
+ // Basic implementation: file size and timestamp comparison
301
+ try {
302
+ if (!existsSync(screenshot1Path) || !existsSync(screenshot2Path)) {
303
+ return {
304
+ changed: true, // Assume changed if files don't exist
305
+ diff: 1.0,
306
+ timeDiff: 0,
307
+ regions: []
308
+ };
309
+ }
310
+
311
+ const stat1 = statSync(screenshot1Path);
312
+ const stat2 = statSync(screenshot2Path);
313
+
314
+ // If file sizes are different, content likely changed
315
+ const sizeDiff = Math.abs(stat1.size - stat2.size) / Math.max(stat1.size, stat2.size);
316
+ const timeDiff = Math.abs(stat1.mtimeMs - stat2.mtimeMs);
317
+
318
+ // Basic check: if size diff > threshold or time diff > 100ms, likely changed
319
+ let changed = sizeDiff > threshold || timeDiff > 100;
320
+
321
+ // Enhanced: Try lightweight pixel comparison if enabled
322
+ if (usePixelComparison && changed) {
323
+ try {
324
+ // Read first few bytes of PNG files to compare
325
+ // PNG files have headers, so identical files will have same header
326
+ const fs = await import('fs');
327
+ const buffer1 = fs.readFileSync(screenshot1Path, { start: 0, end: 1000 });
328
+ const buffer2 = fs.readFileSync(screenshot2Path, { start: 0, end: 1000 });
329
+
330
+ // Compare first 1000 bytes (header + some pixel data)
331
+ let byteMatches = 0;
332
+ const compareLength = Math.min(buffer1.length, buffer2.length, 1000);
333
+ for (let i = 0; i < compareLength; i++) {
334
+ if (buffer1[i] === buffer2[i]) {
335
+ byteMatches++;
336
+ }
337
+ }
338
+
339
+ const byteSimilarity = byteMatches / compareLength;
340
+ // If >95% of bytes match in header region, likely same image
341
+ if (byteSimilarity > 0.95 && sizeDiff < 0.05) {
342
+ changed = false; // Probably same image
343
+ }
344
+ } catch (error) {
345
+ // Fall back to size/timestamp comparison
346
+ warn(`[Visual Change] Pixel comparison failed: ${error.message}`);
347
+ }
348
+ }
349
+
350
+ return {
351
+ changed,
352
+ diff: sizeDiff,
353
+ timeDiff,
354
+ regions: [] // Would need image processing for region detection
355
+ };
356
+ } catch (error) {
357
+ warn(`[Visual Change] Comparison error: ${error.message}`);
358
+ return {
359
+ changed: true, // Assume changed if comparison fails
360
+ diff: 1.0,
361
+ timeDiff: 0,
362
+ regions: []
363
+ };
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Capture screenshots on render changes
369
+ *
370
+ * @param {any} page - Playwright page object
371
+ * @param {Object} options - Capture options
372
+ * @param {Function} [options.onChange] - Callback when change detected
373
+ * @param {number} [options.maxScreenshots] - Maximum screenshots to capture
374
+ * @param {number} [options.duration] - Maximum duration in ms
375
+ * @param {boolean} [options.visualDiff] - Use visual diff detection
376
+ * @param {boolean} [options.detectCSSAnimations] - Detect CSS animations
377
+ * @returns {Promise<Array>} Array of screenshot paths
378
+ */
379
+ export async function captureOnRenderChanges(page, options = {}) {
380
+ const {
381
+ onChange = null,
382
+ maxScreenshots = 100,
383
+ duration = 10000, // 10 seconds default
384
+ visualDiff = false,
385
+ outputDir = 'test-results',
386
+ detectCSSAnimations = true, // Enable CSS animation detection
387
+ detectLayoutChanges = true
388
+ } = options;
389
+
390
+ const screenshots = [];
391
+ const startTime = Date.now();
392
+ let lastScreenshotPath = null;
393
+
394
+ // Set up render change detection (enhanced with CSS animation detection)
395
+ const cleanup = await detectRenderChanges(page, async (changeInfo) => {
396
+ // Check limits
397
+ if (screenshots.length >= maxScreenshots) {
398
+ return;
399
+ }
400
+ if (Date.now() - startTime > duration) {
401
+ return;
402
+ }
403
+
404
+ // Capture screenshot
405
+ const timestamp = Date.now();
406
+ const screenshotPath = `${outputDir}/render-change-${timestamp}.png`;
407
+
408
+ try {
409
+ await page.screenshot({ path: screenshotPath, type: 'png' });
410
+
411
+ // Visual diff check (if enabled)
412
+ if (visualDiff && lastScreenshotPath) {
413
+ const diff = await detectVisualChanges(lastScreenshotPath, screenshotPath);
414
+ if (!diff.changed) {
415
+ // No visual change, skip this screenshot
416
+ const fs = await import('fs');
417
+ if (existsSync(screenshotPath)) {
418
+ fs.unlinkSync(screenshotPath);
419
+ }
420
+ return;
421
+ }
422
+ }
423
+
424
+ screenshots.push({
425
+ path: screenshotPath,
426
+ timestamp,
427
+ changeInfo
428
+ });
429
+
430
+ lastScreenshotPath = screenshotPath;
431
+
432
+ // Call onChange callback
433
+ if (onChange) {
434
+ await onChange(screenshotPath, changeInfo);
435
+ }
436
+ } catch (error) {
437
+ warn(`[Render Change] Screenshot capture error: ${error.message}`);
438
+ }
439
+ }, {
440
+ detectCSSAnimations,
441
+ detectLayoutChanges
442
+ });
443
+
444
+ // Wait for duration or max screenshots
445
+ const checkInterval = setInterval(() => {
446
+ if (screenshots.length >= maxScreenshots || Date.now() - startTime > duration) {
447
+ clearInterval(checkInterval);
448
+ }
449
+ }, 100);
450
+
451
+ // Wait for completion
452
+ await new Promise((resolve) => {
453
+ const finalCheck = setInterval(() => {
454
+ if (screenshots.length >= maxScreenshots || Date.now() - startTime > duration) {
455
+ clearInterval(finalCheck);
456
+ resolve();
457
+ }
458
+ }, 100);
459
+
460
+ // Timeout after duration
461
+ setTimeout(() => {
462
+ clearInterval(finalCheck);
463
+ resolve();
464
+ }, duration);
465
+ });
466
+
467
+ // Cleanup
468
+ await cleanup();
469
+
470
+ return screenshots;
471
+ }
472
+
473
+ /**
474
+ * Adaptive temporal screenshot capture
475
+ *
476
+ * Adjusts capture rate based on detected change rate.
477
+ * Enhanced with CSS animation detection and optimized screenshot quality.
478
+ *
479
+ * @param {any} page - Playwright page object
480
+ * @param {Object} options - Capture options
481
+ * @param {number} [options.minFPS] - Minimum FPS (default: 1)
482
+ * @param {number} [options.maxFPS] - Maximum FPS (default: 60)
483
+ * @param {number} [options.duration] - Duration in ms
484
+ * @param {number} [options.adaptationInterval] - How often to recalculate FPS (ms)
485
+ * @param {boolean} [options.optimizeForSpeed] - Use lower quality screenshots for speed
486
+ * @param {boolean} [options.detectCSSAnimations] - Detect CSS animations
487
+ * @returns {Promise<Array>} Array of screenshot paths with metadata
488
+ */
489
+ export async function captureAdaptiveTemporalScreenshots(page, options = {}) {
490
+ const {
491
+ minFPS = 1,
492
+ maxFPS = 60,
493
+ duration = 10000,
494
+ adaptationInterval = 2000, // Recalculate every 2 seconds
495
+ outputDir = 'test-results',
496
+ optimizeForSpeed = false, // Optimize screenshot quality for speed
497
+ detectCSSAnimations = true // Enable CSS animation detection
498
+ } = options;
499
+
500
+ const screenshots = [];
501
+ const changeHistory = [];
502
+ const startTime = Date.now();
503
+ let currentFPS = minFPS;
504
+ let lastCaptureTime = 0;
505
+
506
+ // Set up render change detection (enhanced with CSS animation detection)
507
+ const cleanup = await detectRenderChanges(page, async (changeInfo) => {
508
+ changeHistory.push({
509
+ timestamp: changeInfo.timestamp,
510
+ mutations: changeInfo.mutations,
511
+ types: changeInfo.types
512
+ });
513
+
514
+ // Keep history limited
515
+ if (changeHistory.length > 100) {
516
+ changeHistory.shift();
517
+ }
518
+ }, {
519
+ detectCSSAnimations,
520
+ detectLayoutChanges: true
521
+ });
522
+
523
+ // Adaptive capture loop
524
+ const captureLoop = async () => {
525
+ while (Date.now() - startTime < duration) {
526
+ // Recalculate optimal FPS periodically
527
+ if (changeHistory.length >= 2) {
528
+ const recentHistory = changeHistory.slice(-20); // Last 20 changes
529
+ currentFPS = calculateOptimalFPS(recentHistory, { minFPS, maxFPS });
530
+ }
531
+
532
+ // Calculate next capture time based on current FPS
533
+ const interval = 1000 / currentFPS;
534
+ const nextCaptureTime = lastCaptureTime + interval;
535
+
536
+ // Wait until next capture time
537
+ const waitTime = Math.max(0, nextCaptureTime - Date.now());
538
+ if (waitTime > 0) {
539
+ await new Promise(resolve => setTimeout(resolve, waitTime));
540
+ }
541
+
542
+ // Capture screenshot (optimize quality for speed if requested)
543
+ const timestamp = Date.now();
544
+ const screenshotPath = `${outputDir}/adaptive-${timestamp}.png`;
545
+
546
+ try {
547
+ // Optimize screenshot quality for high FPS
548
+ const screenshotOptions = {
549
+ path: screenshotPath,
550
+ type: 'png'
551
+ };
552
+
553
+ if (optimizeForSpeed && currentFPS > 30) {
554
+ // For high FPS, use lower quality to reduce overhead
555
+ screenshotOptions.quality = 70; // Lower quality (if supported)
556
+ // Could also reduce size, but Playwright doesn't support that directly
557
+ }
558
+
559
+ await page.screenshot(screenshotOptions);
560
+ screenshots.push({
561
+ path: screenshotPath,
562
+ timestamp,
563
+ fps: currentFPS,
564
+ changeCount: changeHistory.length
565
+ });
566
+ lastCaptureTime = timestamp;
567
+ } catch (error) {
568
+ warn(`[Adaptive Capture] Screenshot error: ${error.message}`);
569
+ }
570
+
571
+ // Small delay to prevent tight loop
572
+ await new Promise(resolve => setTimeout(resolve, 10));
573
+ }
574
+ };
575
+
576
+ // Start capture loop
577
+ await captureLoop();
578
+
579
+ // Cleanup
580
+ await cleanup();
581
+
582
+ return screenshots;
583
+ }