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 +12 -19
- package/dist/index.cjs +85 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +80 -16
- package/dist/index.js.map +1 -1
- package/package.json +5 -19
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.
|
|
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
|
|
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
|
|
436
|
-
|
|
|
437
|
-
| `VISUAL_AI_MODEL`
|
|
438
|
-
| `VISUAL_AI_DEBUG`
|
|
439
|
-
| `VISUAL_AI_DEBUG_PROMPT`
|
|
440
|
-
| `VISUAL_AI_DEBUG_RESPONSE`
|
|
441
|
-
| `
|
|
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/
|
|
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,
|
|
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,
|
|
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,
|
|
1418
|
-
await (0,
|
|
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,
|
|
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,
|
|
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
|
-
"
|
|
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((
|
|
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
|
-
|
|
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,
|
|
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((
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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 {
|