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/dist/index.d.cts CHANGED
@@ -74,6 +74,10 @@ declare const Accessibility: {
74
74
  readonly READABILITY: "readability";
75
75
  /** Detects interactive elements that are not visually distinct */
76
76
  readonly INTERACTIVE_VISIBILITY: "interactive-visibility";
77
+ /** Detects color choices likely to be indistinguishable to viewers with common color vision deficiencies */
78
+ readonly COLOR_BLINDNESS: "color-blindness";
79
+ /** Detects information conveyed by color alone, without a non-color cue (icon, text, pattern, position) */
80
+ readonly COLOR_ALONE: "color-alone";
77
81
  };
78
82
  /** Union of all built-in content check names. */
79
83
  type ContentCheckName = (typeof Content)[keyof typeof Content];
package/dist/index.d.ts CHANGED
@@ -74,6 +74,10 @@ declare const Accessibility: {
74
74
  readonly READABILITY: "readability";
75
75
  /** Detects interactive elements that are not visually distinct */
76
76
  readonly INTERACTIVE_VISIBILITY: "interactive-visibility";
77
+ /** Detects color choices likely to be indistinguishable to viewers with common color vision deficiencies */
78
+ readonly COLOR_BLINDNESS: "color-blindness";
79
+ /** Detects information conveyed by color alone, without a non-color cue (icon, text, pattern, position) */
80
+ readonly COLOR_ALONE: "color-alone";
77
81
  };
78
82
  /** Union of all built-in content check names. */
79
83
  type ContentCheckName = (typeof Content)[keyof typeof Content];
package/dist/index.js CHANGED
@@ -74,7 +74,11 @@ var Accessibility = {
74
74
  /** Detects text that is cut off, overlapping, too small, or obscured */
75
75
  READABILITY: "readability",
76
76
  /** Detects interactive elements that are not visually distinct */
77
- INTERACTIVE_VISIBILITY: "interactive-visibility"
77
+ INTERACTIVE_VISIBILITY: "interactive-visibility",
78
+ /** Detects color choices likely to be indistinguishable to viewers with common color vision deficiencies */
79
+ COLOR_BLINDNESS: "color-blindness",
80
+ /** Detects information conveyed by color alone, without a non-color cue (icon, text, pattern, position) */
81
+ COLOR_ALONE: "color-alone"
78
82
  };
79
83
 
80
84
  // src/errors.ts
@@ -399,12 +403,16 @@ var ALL_CHECKS = Object.values(Accessibility);
399
403
  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.";
400
404
  var ACCESSIBILITY_EDGE_RULES = [
401
405
  "Do not state specific contrast ratios. Describe contrast as 'appears sufficient' or 'appears low'.",
402
- "Dark mode and light mode themes are both valid. Do not flag a valid dark theme as a contrast issue."
406
+ "Dark mode and light mode themes are both valid. Do not flag a valid dark theme as a contrast issue.",
407
+ "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.",
408
+ "Only evaluate color cues that are visible in the screenshot. Do not assume hover/focus state colors exist if they are not shown."
403
409
  ];
404
410
  var CHECK_STATEMENTS = {
405
411
  [Accessibility.CONTRAST]: "All text and interactive elements appear to have sufficient color contrast \u2014 text is clearly readable against its background",
406
412
  [Accessibility.READABILITY]: "All text is readable \u2014 no text is cut off, overlapping, too small to read, or obscured by background images",
407
- [Accessibility.INTERACTIVE_VISIBILITY]: "All interactive elements (buttons, links, inputs) are clearly identifiable and visually distinct from non-interactive content"
413
+ [Accessibility.INTERACTIVE_VISIBILITY]: "All interactive elements (buttons, links, inputs) are clearly identifiable and visually distinct from non-interactive content",
414
+ [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)",
415
+ [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)"
408
416
  };
409
417
  function buildAccessibilityPrompt(options) {
410
418
  const checks = options?.checks ?? [...ALL_CHECKS];
@@ -1241,10 +1249,65 @@ async function normalizeImage(input) {
1241
1249
  };
1242
1250
  }
1243
1251
 
1252
+ // src/core/debug-frames.ts
1253
+ import { randomBytes } from "crypto";
1254
+ import { mkdir, writeFile } from "fs/promises";
1255
+ import { join, resolve } from "path";
1256
+ var DEBUG_FRAMES_ENV = "VISUAL_AI_DEBUG_FRAMES";
1257
+ var DEBUG_FRAMES_DIR_ENV = "VISUAL_AI_DEBUG_FRAMES_DIR";
1258
+ var DEFAULT_DIR_NAME = "visual-ai-debug-frames";
1259
+ function isEnabled(env) {
1260
+ const raw = env[DEBUG_FRAMES_ENV];
1261
+ if (raw === void 0 || raw === "") return false;
1262
+ const lower = raw.toLowerCase();
1263
+ return lower === "true" || lower === "1";
1264
+ }
1265
+ function timestampSlug(date) {
1266
+ return date.toISOString().replace(/[:.]/g, "-");
1267
+ }
1268
+ function paddedIndex(value, total) {
1269
+ const width = Math.max(2, String(total - 1).length);
1270
+ return String(value).padStart(width, "0");
1271
+ }
1272
+ function extensionFromMimeType(mimeType) {
1273
+ if (mimeType === "image/png") return ".png";
1274
+ if (mimeType === "image/webp") return ".webp";
1275
+ return ".jpg";
1276
+ }
1277
+ async function saveDebugFrames(frames, env = process.env) {
1278
+ if (!isEnabled(env)) return void 0;
1279
+ if (frames.length === 0) return void 0;
1280
+ const baseDir = env[DEBUG_FRAMES_DIR_ENV]?.trim() || DEFAULT_DIR_NAME;
1281
+ const runDir = resolve(baseDir, `${timestampSlug(/* @__PURE__ */ new Date())}-${randomBytes(3).toString("hex")}`);
1282
+ try {
1283
+ await mkdir(runDir, { recursive: true });
1284
+ await Promise.all(
1285
+ frames.map((frame) => {
1286
+ const idx = paddedIndex(frame.index, frames.length);
1287
+ const ts = frame.timestampSeconds.toFixed(2);
1288
+ const ext = extensionFromMimeType(frame.mimeType);
1289
+ const filename = `frame-${idx}-t${ts}s${ext}`;
1290
+ return writeFile(join(runDir, filename), frame.data);
1291
+ })
1292
+ );
1293
+ } catch (err) {
1294
+ process.stderr.write(
1295
+ `[visual-ai-assertions] warning: failed to save debug frames to ${runDir}: ${err instanceof Error ? err.message : String(err)}
1296
+ `
1297
+ );
1298
+ return void 0;
1299
+ }
1300
+ process.stderr.write(
1301
+ `[visual-ai-assertions] Saved ${frames.length} debug frame(s) to ${runDir}
1302
+ `
1303
+ );
1304
+ return runDir;
1305
+ }
1306
+
1244
1307
  // src/core/video.ts
1245
- import { mkdtemp, readFile as readFile2, readdir, rm, writeFile } from "fs/promises";
1308
+ import { mkdtemp, readFile as readFile2, readdir, rm, writeFile as writeFile2 } from "fs/promises";
1246
1309
  import { tmpdir } from "os";
1247
- import { extname as extname2, join } from "path";
1310
+ import { extname as extname2, join as join2 } from "path";
1248
1311
  var FRAME_MAX_DIMENSION = 1568;
1249
1312
  var DEFAULT_FPS = 1;
1250
1313
  var DEFAULT_MAX_FRAMES = 10;
@@ -1343,11 +1406,11 @@ async function resolveVideoToPath(input) {
1343
1406
  return writeBufferToTemp(buf, mimeType);
1344
1407
  }
1345
1408
  async function writeBufferToTemp(data, mimeType) {
1346
- const dir = await mkdtemp(join(tmpdir(), "visual-ai-video-"));
1409
+ const dir = await mkdtemp(join2(tmpdir(), "visual-ai-video-"));
1347
1410
  try {
1348
1411
  const ext = extensionFor(mimeType);
1349
- const path = join(dir, `input${ext}`);
1350
- await writeFile(path, data);
1412
+ const path = join2(dir, `input${ext}`);
1413
+ await writeFile2(path, data);
1351
1414
  return {
1352
1415
  path,
1353
1416
  mimeType,
@@ -1389,7 +1452,7 @@ async function loadFfmpegFactory() {
1389
1452
  const code = err?.code;
1390
1453
  if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
1391
1454
  throw new VisualAIVideoError(
1392
- "Video support requires fluent-ffmpeg. Install it with: pnpm add -D fluent-ffmpeg @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe @types/fluent-ffmpeg"
1455
+ "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"
1393
1456
  );
1394
1457
  }
1395
1458
  throw new VisualAIVideoError(
@@ -1434,7 +1497,7 @@ async function loadFfmpegFactory() {
1434
1497
  }
1435
1498
  async function probeDurationSeconds(videoPath) {
1436
1499
  const ffmpeg = await loadFfmpegFactory();
1437
- return new Promise((resolve, reject) => {
1500
+ return new Promise((resolve2, reject) => {
1438
1501
  let settled = false;
1439
1502
  const finish = (fn) => {
1440
1503
  if (settled) return;
@@ -1471,7 +1534,7 @@ async function probeDurationSeconds(videoPath) {
1471
1534
  return;
1472
1535
  }
1473
1536
  finish(() => {
1474
- resolve(duration);
1537
+ resolve2(duration);
1475
1538
  });
1476
1539
  });
1477
1540
  });
@@ -1503,10 +1566,10 @@ async function extractFrames(videoPath, options = {}) {
1503
1566
  `Video duration ${durationSeconds.toFixed(2)}s exceeds limit of ${maxDurationSeconds}s. Pass { maxDurationSeconds: N } to override, or trim the source video.`
1504
1567
  );
1505
1568
  }
1506
- const outputDir = await mkdtemp(join(tmpdir(), "visual-ai-frames-"));
1569
+ const outputDir = await mkdtemp(join2(tmpdir(), "visual-ai-frames-"));
1507
1570
  try {
1508
1571
  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`;
1509
- await new Promise((resolve, reject) => {
1572
+ await new Promise((resolve2, reject) => {
1510
1573
  let settled = false;
1511
1574
  const cmd = ffmpeg(videoPath);
1512
1575
  const finish = (fn) => {
@@ -1528,9 +1591,9 @@ async function extractFrames(videoPath, options = {}) {
1528
1591
  );
1529
1592
  });
1530
1593
  }, FFMPEG_RUN_TIMEOUT_MS);
1531
- cmd.outputOptions(["-vf", filter, "-vframes", String(maxFrames), "-q:v", "3"]).output(join(outputDir, "frame-%04d.jpg")).on("end", () => {
1594
+ cmd.outputOptions(["-vf", filter, "-vframes", String(maxFrames), "-q:v", "3"]).output(join2(outputDir, "frame-%04d.jpg")).on("end", () => {
1532
1595
  finish(() => {
1533
- resolve();
1596
+ resolve2();
1534
1597
  });
1535
1598
  }).on("error", (err) => {
1536
1599
  finish(() => {
@@ -1546,7 +1609,7 @@ async function extractFrames(videoPath, options = {}) {
1546
1609
  }
1547
1610
  const frames = await Promise.all(
1548
1611
  files.map(async (name, index) => {
1549
- const data = await readFile2(join(outputDir, name));
1612
+ const data = await readFile2(join2(outputDir, name));
1550
1613
  const timestampSeconds = Math.min(durationSeconds, (index + 0.5) / fps);
1551
1614
  let cachedBase64;
1552
1615
  return {
@@ -1602,6 +1665,7 @@ async function normalizeMedia(input, videoOptions) {
1602
1665
  const { path, cleanup } = await resolveVideoToPath(input);
1603
1666
  try {
1604
1667
  const { frames, durationSeconds } = await extractFrames(path, videoOptions);
1668
+ await saveDebugFrames(frames);
1605
1669
  return { kind: "video", frames, durationSeconds };
1606
1670
  } finally {
1607
1671
  try {