visual-ai-assertions 0.9.0 → 0.11.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 CHANGED
@@ -12,9 +12,6 @@ npm install visual-ai-assertions
12
12
  npm install @anthropic-ai/sdk # for Claude
13
13
  npm install @google/genai # for Gemini
14
14
 
15
- # Optional: install ffmpeg deps to enable video input
16
- npm install --save-dev fluent-ffmpeg @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe
17
-
18
15
  # Zod is a peer dependency
19
16
  npm install zod
20
17
  ```
@@ -256,10 +253,10 @@ await ai.elementsVisible(screenshot, ["Submit button", "Nav bar", "Footer"]);
256
253
  // Check that UI elements are hidden
257
254
  await ai.elementsHidden(screenshot, ["Loading spinner", "Error modal"]);
258
255
 
259
- // Accessibility checks (contrast, readability, interactive visibility)
256
+ // Accessibility checks (contrast, readability, interactive visibility, color blindness, color-alone meaning)
260
257
  await ai.accessibility(screenshot);
261
258
  await ai.accessibility(screenshot, {
262
- checks: [Accessibility.CONTRAST, Accessibility.READABILITY],
259
+ checks: [Accessibility.CONTRAST, Accessibility.COLOR_BLINDNESS, Accessibility.COLOR_ALONE],
263
260
  });
264
261
 
265
262
  // Layout checks (overlap, overflow, alignment)
@@ -346,13 +343,7 @@ await ai.check("./long-clip.mp4", ["Loader disappears"], {
346
343
 
347
344
  How it works: the library samples frames with ffmpeg and sends them to the provider as an ordered timeline. A statement passes when it is true at any sampled frame, unless its wording specifies otherwise (e.g. "throughout"). Template helpers (`accessibility`, `layout`, `pageLoad`, `content`, `elementsVisible`, `elementsHidden`) are image-only — pass video to `check()` or `ask()` instead.
348
345
 
349
- **ffmpeg setup.** Video support is gated on three optional peer deps:
350
-
351
- ```bash
352
- npm install --save-dev fluent-ffmpeg @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe
353
- ```
354
-
355
- Calling `check()` or `ask()` with a video input throws `VisualAIVideoError` (import from `visual-ai-assertions` to `instanceof`-narrow it) if these packages aren't installed. If you already have `ffmpeg`/`ffprobe` on `PATH`, only `fluent-ffmpeg` is required.
346
+ **ffmpeg setup.** Video support works out of the box — `fluent-ffmpeg`, `@ffmpeg-installer/ffmpeg`, and `@ffprobe-installer/ffprobe` ship as regular dependencies and bundle platform-specific ffmpeg/ffprobe binaries. If you ran `npm install` you already have everything you need. On platforms where the prebuilt binary is unavailable (or if you've pruned dependencies), `check()` and `ask()` throw `VisualAIVideoError` (import from `visual-ai-assertions` to `instanceof`-narrow it) when called with video input.
356
347
 
357
348
  ### Formatting & Assertion Helpers
358
349
 
@@ -432,13 +423,15 @@ The `VisualAIKnownError` union and `isVisualAIKnownError()` helper are useful wh
432
423
 
433
424
  ### Optional Configuration
434
425
 
435
- | Variable | Description |
436
- | -------------------------- | -------------------------------------------------------------------------------------------------------------- |
437
- | `VISUAL_AI_MODEL` | Default model when `model` is not set in config. Overrides the provider's default model. |
438
- | `VISUAL_AI_DEBUG` | Enable error diagnostic logging to stderr. Does **not** enable prompt/response logging. Use `"true"` or `"1"`. |
439
- | `VISUAL_AI_DEBUG_PROMPT` | Enable prompt-only debug logging to stderr. Use `"true"` or `"1"`. |
440
- | `VISUAL_AI_DEBUG_RESPONSE` | Enable response-only debug logging to stderr. Use `"true"` or `"1"`. |
441
- | `VISUAL_AI_TRACK_USAGE` | Enable usage tracking (token counts and cost) to stderr. Use `"true"` or `"1"`. |
426
+ | Variable | Description |
427
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
428
+ | `VISUAL_AI_MODEL` | Default model when `model` is not set in config. Overrides the provider's default model. |
429
+ | `VISUAL_AI_DEBUG` | Enable error diagnostic logging to stderr. Does **not** enable prompt/response logging. Use `"true"` or `"1"`. |
430
+ | `VISUAL_AI_DEBUG_PROMPT` | Enable prompt-only debug logging to stderr. Use `"true"` or `"1"`. |
431
+ | `VISUAL_AI_DEBUG_RESPONSE` | Enable response-only debug logging to stderr. Use `"true"` or `"1"`. |
432
+ | `VISUAL_AI_DEBUG_FRAMES` | Persist sampled video frames to disk for offline inspection. Use `"true"` or `"1"`. Frames are written to `./visual-ai-debug-frames/<timestamp>-<id>/` (override path with the next variable). Has no effect on image-only inputs. |
433
+ | `VISUAL_AI_DEBUG_FRAMES_DIR` | Override the base directory for `VISUAL_AI_DEBUG_FRAMES`. Each call still gets its own timestamped subdirectory inside it. |
434
+ | `VISUAL_AI_TRACK_USAGE` | Enable usage tracking (token counts and cost) to stderr. Use `"true"` or `"1"`. |
442
435
 
443
436
  ## Configuration
444
437
 
package/dist/index.cjs CHANGED
@@ -142,7 +142,11 @@ var Accessibility = {
142
142
  /** Detects text that is cut off, overlapping, too small, or obscured */
143
143
  READABILITY: "readability",
144
144
  /** Detects interactive elements that are not visually distinct */
145
- INTERACTIVE_VISIBILITY: "interactive-visibility"
145
+ INTERACTIVE_VISIBILITY: "interactive-visibility",
146
+ /** Detects color choices likely to be indistinguishable to viewers with common color vision deficiencies */
147
+ COLOR_BLINDNESS: "color-blindness",
148
+ /** Detects information conveyed by color alone, without a non-color cue (icon, text, pattern, position) */
149
+ COLOR_ALONE: "color-alone"
146
150
  };
147
151
 
148
152
  // src/errors.ts
@@ -467,12 +471,16 @@ var ALL_CHECKS = Object.values(Accessibility);
467
471
  var ACCESSIBILITY_ROLE = "Evaluate this screenshot for visual accessibility. Focus on what you can actually perceive \u2014 apparent contrast levels, text legibility, and visual distinctiveness of interactive elements.";
468
472
  var ACCESSIBILITY_EDGE_RULES = [
469
473
  "Do not state specific contrast ratios. Describe contrast as 'appears sufficient' or 'appears low'.",
470
- "Dark mode and light mode themes are both valid. Do not flag a valid dark theme as a contrast issue."
474
+ "Dark mode and light mode themes are both valid. Do not flag a valid dark theme as a contrast issue.",
475
+ "Purely decorative color (branding, backgrounds, gradients) is not a color-blindness or color-alone issue \u2014 only flag color usage that carries meaning a user must perceive to use the interface.",
476
+ "Only evaluate color cues that are visible in the screenshot. Do not assume hover/focus state colors exist if they are not shown."
471
477
  ];
472
478
  var CHECK_STATEMENTS = {
473
479
  [Accessibility.CONTRAST]: "All text and interactive elements appear to have sufficient color contrast \u2014 text is clearly readable against its background",
474
480
  [Accessibility.READABILITY]: "All text is readable \u2014 no text is cut off, overlapping, too small to read, or obscured by background images",
475
- [Accessibility.INTERACTIVE_VISIBILITY]: "All interactive elements (buttons, links, inputs) are clearly identifiable and visually distinct from non-interactive content"
481
+ [Accessibility.INTERACTIVE_VISIBILITY]: "All interactive elements (buttons, links, inputs) are clearly identifiable and visually distinct from non-interactive content",
482
+ [Accessibility.COLOR_BLINDNESS]: "Information conveyed by color remains distinguishable to viewers with common color vision deficiencies \u2014 status indicators, chart series, and other meaningful color pairings do not rely on hue combinations that are commonly confused (e.g., red/green, blue/purple)",
483
+ [Accessibility.COLOR_ALONE]: "Information conveyed by color is also conveyed through at least one non-color cue \u2014 text labels, icons, shapes, patterns, underlines, or position accompany any color-based meaning (e.g., required fields, error states, chart legends, link styling)"
476
484
  };
477
485
  function buildAccessibilityPrompt(options) {
478
486
  const checks = options?.checks ?? [...ALL_CHECKS];
@@ -1309,10 +1317,65 @@ async function normalizeImage(input) {
1309
1317
  };
1310
1318
  }
1311
1319
 
1312
- // src/core/video.ts
1320
+ // src/core/debug-frames.ts
1321
+ var import_node_crypto = require("crypto");
1313
1322
  var import_promises2 = require("fs/promises");
1314
- var import_node_os = require("os");
1315
1323
  var import_node_path2 = require("path");
1324
+ var DEBUG_FRAMES_ENV = "VISUAL_AI_DEBUG_FRAMES";
1325
+ var DEBUG_FRAMES_DIR_ENV = "VISUAL_AI_DEBUG_FRAMES_DIR";
1326
+ var DEFAULT_DIR_NAME = "visual-ai-debug-frames";
1327
+ function isEnabled(env) {
1328
+ const raw = env[DEBUG_FRAMES_ENV];
1329
+ if (raw === void 0 || raw === "") return false;
1330
+ const lower = raw.toLowerCase();
1331
+ return lower === "true" || lower === "1";
1332
+ }
1333
+ function timestampSlug(date) {
1334
+ return date.toISOString().replace(/[:.]/g, "-");
1335
+ }
1336
+ function paddedIndex(value, total) {
1337
+ const width = Math.max(2, String(total - 1).length);
1338
+ return String(value).padStart(width, "0");
1339
+ }
1340
+ function extensionFromMimeType(mimeType) {
1341
+ if (mimeType === "image/png") return ".png";
1342
+ if (mimeType === "image/webp") return ".webp";
1343
+ return ".jpg";
1344
+ }
1345
+ async function saveDebugFrames(frames, env = process.env) {
1346
+ if (!isEnabled(env)) return void 0;
1347
+ if (frames.length === 0) return void 0;
1348
+ const baseDir = env[DEBUG_FRAMES_DIR_ENV]?.trim() || DEFAULT_DIR_NAME;
1349
+ const runDir = (0, import_node_path2.resolve)(baseDir, `${timestampSlug(/* @__PURE__ */ new Date())}-${(0, import_node_crypto.randomBytes)(3).toString("hex")}`);
1350
+ try {
1351
+ await (0, import_promises2.mkdir)(runDir, { recursive: true });
1352
+ await Promise.all(
1353
+ frames.map((frame) => {
1354
+ const idx = paddedIndex(frame.index, frames.length);
1355
+ const ts = frame.timestampSeconds.toFixed(2);
1356
+ const ext = extensionFromMimeType(frame.mimeType);
1357
+ const filename = `frame-${idx}-t${ts}s${ext}`;
1358
+ return (0, import_promises2.writeFile)((0, import_node_path2.join)(runDir, filename), frame.data);
1359
+ })
1360
+ );
1361
+ } catch (err) {
1362
+ process.stderr.write(
1363
+ `[visual-ai-assertions] warning: failed to save debug frames to ${runDir}: ${err instanceof Error ? err.message : String(err)}
1364
+ `
1365
+ );
1366
+ return void 0;
1367
+ }
1368
+ process.stderr.write(
1369
+ `[visual-ai-assertions] Saved ${frames.length} debug frame(s) to ${runDir}
1370
+ `
1371
+ );
1372
+ return runDir;
1373
+ }
1374
+
1375
+ // src/core/video.ts
1376
+ var import_promises3 = require("fs/promises");
1377
+ var import_node_os = require("os");
1378
+ var import_node_path3 = require("path");
1316
1379
  var FRAME_MAX_DIMENSION = 1568;
1317
1380
  var DEFAULT_FPS = 1;
1318
1381
  var DEFAULT_MAX_FRAMES = 10;
@@ -1338,7 +1401,7 @@ function isSupportedVideoMimeType(value) {
1338
1401
  return VIDEO_MIME_TYPES.has(value);
1339
1402
  }
1340
1403
  function getVideoMimeFromExtension(filePath) {
1341
- const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
1404
+ const ext = (0, import_node_path3.extname)(filePath).toLowerCase();
1342
1405
  return VIDEO_EXTENSIONS[ext];
1343
1406
  }
1344
1407
  function detectVideoMimeType(data) {
@@ -1411,24 +1474,24 @@ async function resolveVideoToPath(input) {
1411
1474
  return writeBufferToTemp(buf, mimeType);
1412
1475
  }
1413
1476
  async function writeBufferToTemp(data, mimeType) {
1414
- const dir = await (0, import_promises2.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "visual-ai-video-"));
1477
+ const dir = await (0, import_promises3.mkdtemp)((0, import_node_path3.join)((0, import_node_os.tmpdir)(), "visual-ai-video-"));
1415
1478
  try {
1416
1479
  const ext = extensionFor(mimeType);
1417
- const path = (0, import_node_path2.join)(dir, `input${ext}`);
1418
- await (0, import_promises2.writeFile)(path, data);
1480
+ const path = (0, import_node_path3.join)(dir, `input${ext}`);
1481
+ await (0, import_promises3.writeFile)(path, data);
1419
1482
  return {
1420
1483
  path,
1421
1484
  mimeType,
1422
1485
  cleanup: async () => {
1423
1486
  try {
1424
- await (0, import_promises2.rm)(dir, { recursive: true, force: true });
1487
+ await (0, import_promises3.rm)(dir, { recursive: true, force: true });
1425
1488
  } catch {
1426
1489
  }
1427
1490
  }
1428
1491
  };
1429
1492
  } catch (err) {
1430
1493
  try {
1431
- await (0, import_promises2.rm)(dir, { recursive: true, force: true });
1494
+ await (0, import_promises3.rm)(dir, { recursive: true, force: true });
1432
1495
  } catch {
1433
1496
  }
1434
1497
  throw err;
@@ -1457,7 +1520,7 @@ async function loadFfmpegFactory() {
1457
1520
  const code = err?.code;
1458
1521
  if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
1459
1522
  throw new VisualAIVideoError(
1460
- "Video support requires fluent-ffmpeg. Install it with: pnpm add -D fluent-ffmpeg @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe @types/fluent-ffmpeg"
1523
+ "Could not load fluent-ffmpeg. It ships as a dependency of visual-ai-assertions, so this usually means the install was pruned or the platform-specific binary is unavailable. Reinstall the package or run: pnpm add fluent-ffmpeg @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe"
1461
1524
  );
1462
1525
  }
1463
1526
  throw new VisualAIVideoError(
@@ -1502,7 +1565,7 @@ async function loadFfmpegFactory() {
1502
1565
  }
1503
1566
  async function probeDurationSeconds(videoPath) {
1504
1567
  const ffmpeg = await loadFfmpegFactory();
1505
- return new Promise((resolve, reject) => {
1568
+ return new Promise((resolve2, reject) => {
1506
1569
  let settled = false;
1507
1570
  const finish = (fn) => {
1508
1571
  if (settled) return;
@@ -1539,7 +1602,7 @@ async function probeDurationSeconds(videoPath) {
1539
1602
  return;
1540
1603
  }
1541
1604
  finish(() => {
1542
- resolve(duration);
1605
+ resolve2(duration);
1543
1606
  });
1544
1607
  });
1545
1608
  });
@@ -1571,10 +1634,10 @@ async function extractFrames(videoPath, options = {}) {
1571
1634
  `Video duration ${durationSeconds.toFixed(2)}s exceeds limit of ${maxDurationSeconds}s. Pass { maxDurationSeconds: N } to override, or trim the source video.`
1572
1635
  );
1573
1636
  }
1574
- const outputDir = await (0, import_promises2.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "visual-ai-frames-"));
1637
+ const outputDir = await (0, import_promises3.mkdtemp)((0, import_node_path3.join)((0, import_node_os.tmpdir)(), "visual-ai-frames-"));
1575
1638
  try {
1576
1639
  const filter = `fps=${fps},scale='if(gt(iw,ih),min(${FRAME_MAX_DIMENSION},iw),-2)':'if(gt(iw,ih),-2,min(${FRAME_MAX_DIMENSION},ih))':flags=area`;
1577
- await new Promise((resolve, reject) => {
1640
+ await new Promise((resolve2, reject) => {
1578
1641
  let settled = false;
1579
1642
  const cmd = ffmpeg(videoPath);
1580
1643
  const finish = (fn) => {
@@ -1596,9 +1659,9 @@ async function extractFrames(videoPath, options = {}) {
1596
1659
  );
1597
1660
  });
1598
1661
  }, FFMPEG_RUN_TIMEOUT_MS);
1599
- cmd.outputOptions(["-vf", filter, "-vframes", String(maxFrames), "-q:v", "3"]).output((0, import_node_path2.join)(outputDir, "frame-%04d.jpg")).on("end", () => {
1662
+ cmd.outputOptions(["-vf", filter, "-vframes", String(maxFrames), "-q:v", "3"]).output((0, import_node_path3.join)(outputDir, "frame-%04d.jpg")).on("end", () => {
1600
1663
  finish(() => {
1601
- resolve();
1664
+ resolve2();
1602
1665
  });
1603
1666
  }).on("error", (err) => {
1604
1667
  finish(() => {
@@ -1606,7 +1669,7 @@ async function extractFrames(videoPath, options = {}) {
1606
1669
  });
1607
1670
  }).run();
1608
1671
  });
1609
- const files = (await (0, import_promises2.readdir)(outputDir)).filter((name) => name.endsWith(".jpg")).sort();
1672
+ const files = (await (0, import_promises3.readdir)(outputDir)).filter((name) => name.endsWith(".jpg")).sort();
1610
1673
  if (files.length === 0) {
1611
1674
  throw new VisualAIVideoError(
1612
1675
  "No frames could be extracted from the video. The source may be corrupt or empty."
@@ -1614,7 +1677,7 @@ async function extractFrames(videoPath, options = {}) {
1614
1677
  }
1615
1678
  const frames = await Promise.all(
1616
1679
  files.map(async (name, index) => {
1617
- const data = await (0, import_promises2.readFile)((0, import_node_path2.join)(outputDir, name));
1680
+ const data = await (0, import_promises3.readFile)((0, import_node_path3.join)(outputDir, name));
1618
1681
  const timestampSeconds = Math.min(durationSeconds, (index + 0.5) / fps);
1619
1682
  let cachedBase64;
1620
1683
  return {
@@ -1634,7 +1697,7 @@ async function extractFrames(videoPath, options = {}) {
1634
1697
  return { frames, durationSeconds };
1635
1698
  } finally {
1636
1699
  try {
1637
- await (0, import_promises2.rm)(outputDir, { recursive: true, force: true });
1700
+ await (0, import_promises3.rm)(outputDir, { recursive: true, force: true });
1638
1701
  } catch {
1639
1702
  }
1640
1703
  }
@@ -1670,6 +1733,7 @@ async function normalizeMedia(input, videoOptions) {
1670
1733
  const { path, cleanup } = await resolveVideoToPath(input);
1671
1734
  try {
1672
1735
  const { frames, durationSeconds } = await extractFrames(path, videoOptions);
1736
+ await saveDebugFrames(frames);
1673
1737
  return { kind: "video", frames, durationSeconds };
1674
1738
  } finally {
1675
1739
  try {