visual-ai-assertions 0.9.0 → 0.10.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 +10 -17
- package/dist/index.cjs +74 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +69 -13
- package/dist/index.js.map +1 -1
- package/package.json +40 -30
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
|
```
|
|
@@ -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
|
@@ -1309,10 +1309,65 @@ async function normalizeImage(input) {
|
|
|
1309
1309
|
};
|
|
1310
1310
|
}
|
|
1311
1311
|
|
|
1312
|
-
// src/core/
|
|
1312
|
+
// src/core/debug-frames.ts
|
|
1313
|
+
var import_node_crypto = require("crypto");
|
|
1313
1314
|
var import_promises2 = require("fs/promises");
|
|
1314
|
-
var import_node_os = require("os");
|
|
1315
1315
|
var import_node_path2 = require("path");
|
|
1316
|
+
var DEBUG_FRAMES_ENV = "VISUAL_AI_DEBUG_FRAMES";
|
|
1317
|
+
var DEBUG_FRAMES_DIR_ENV = "VISUAL_AI_DEBUG_FRAMES_DIR";
|
|
1318
|
+
var DEFAULT_DIR_NAME = "visual-ai-debug-frames";
|
|
1319
|
+
function isEnabled(env) {
|
|
1320
|
+
const raw = env[DEBUG_FRAMES_ENV];
|
|
1321
|
+
if (raw === void 0 || raw === "") return false;
|
|
1322
|
+
const lower = raw.toLowerCase();
|
|
1323
|
+
return lower === "true" || lower === "1";
|
|
1324
|
+
}
|
|
1325
|
+
function timestampSlug(date) {
|
|
1326
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
1327
|
+
}
|
|
1328
|
+
function paddedIndex(value, total) {
|
|
1329
|
+
const width = Math.max(2, String(total - 1).length);
|
|
1330
|
+
return String(value).padStart(width, "0");
|
|
1331
|
+
}
|
|
1332
|
+
function extensionFromMimeType(mimeType) {
|
|
1333
|
+
if (mimeType === "image/png") return ".png";
|
|
1334
|
+
if (mimeType === "image/webp") return ".webp";
|
|
1335
|
+
return ".jpg";
|
|
1336
|
+
}
|
|
1337
|
+
async function saveDebugFrames(frames, env = process.env) {
|
|
1338
|
+
if (!isEnabled(env)) return void 0;
|
|
1339
|
+
if (frames.length === 0) return void 0;
|
|
1340
|
+
const baseDir = env[DEBUG_FRAMES_DIR_ENV]?.trim() || DEFAULT_DIR_NAME;
|
|
1341
|
+
const runDir = (0, import_node_path2.resolve)(baseDir, `${timestampSlug(/* @__PURE__ */ new Date())}-${(0, import_node_crypto.randomBytes)(3).toString("hex")}`);
|
|
1342
|
+
try {
|
|
1343
|
+
await (0, import_promises2.mkdir)(runDir, { recursive: true });
|
|
1344
|
+
await Promise.all(
|
|
1345
|
+
frames.map((frame) => {
|
|
1346
|
+
const idx = paddedIndex(frame.index, frames.length);
|
|
1347
|
+
const ts = frame.timestampSeconds.toFixed(2);
|
|
1348
|
+
const ext = extensionFromMimeType(frame.mimeType);
|
|
1349
|
+
const filename = `frame-${idx}-t${ts}s${ext}`;
|
|
1350
|
+
return (0, import_promises2.writeFile)((0, import_node_path2.join)(runDir, filename), frame.data);
|
|
1351
|
+
})
|
|
1352
|
+
);
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
process.stderr.write(
|
|
1355
|
+
`[visual-ai-assertions] warning: failed to save debug frames to ${runDir}: ${err instanceof Error ? err.message : String(err)}
|
|
1356
|
+
`
|
|
1357
|
+
);
|
|
1358
|
+
return void 0;
|
|
1359
|
+
}
|
|
1360
|
+
process.stderr.write(
|
|
1361
|
+
`[visual-ai-assertions] Saved ${frames.length} debug frame(s) to ${runDir}
|
|
1362
|
+
`
|
|
1363
|
+
);
|
|
1364
|
+
return runDir;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// src/core/video.ts
|
|
1368
|
+
var import_promises3 = require("fs/promises");
|
|
1369
|
+
var import_node_os = require("os");
|
|
1370
|
+
var import_node_path3 = require("path");
|
|
1316
1371
|
var FRAME_MAX_DIMENSION = 1568;
|
|
1317
1372
|
var DEFAULT_FPS = 1;
|
|
1318
1373
|
var DEFAULT_MAX_FRAMES = 10;
|
|
@@ -1338,7 +1393,7 @@ function isSupportedVideoMimeType(value) {
|
|
|
1338
1393
|
return VIDEO_MIME_TYPES.has(value);
|
|
1339
1394
|
}
|
|
1340
1395
|
function getVideoMimeFromExtension(filePath) {
|
|
1341
|
-
const ext = (0,
|
|
1396
|
+
const ext = (0, import_node_path3.extname)(filePath).toLowerCase();
|
|
1342
1397
|
return VIDEO_EXTENSIONS[ext];
|
|
1343
1398
|
}
|
|
1344
1399
|
function detectVideoMimeType(data) {
|
|
@@ -1411,24 +1466,24 @@ async function resolveVideoToPath(input) {
|
|
|
1411
1466
|
return writeBufferToTemp(buf, mimeType);
|
|
1412
1467
|
}
|
|
1413
1468
|
async function writeBufferToTemp(data, mimeType) {
|
|
1414
|
-
const dir = await (0,
|
|
1469
|
+
const dir = await (0, import_promises3.mkdtemp)((0, import_node_path3.join)((0, import_node_os.tmpdir)(), "visual-ai-video-"));
|
|
1415
1470
|
try {
|
|
1416
1471
|
const ext = extensionFor(mimeType);
|
|
1417
|
-
const path = (0,
|
|
1418
|
-
await (0,
|
|
1472
|
+
const path = (0, import_node_path3.join)(dir, `input${ext}`);
|
|
1473
|
+
await (0, import_promises3.writeFile)(path, data);
|
|
1419
1474
|
return {
|
|
1420
1475
|
path,
|
|
1421
1476
|
mimeType,
|
|
1422
1477
|
cleanup: async () => {
|
|
1423
1478
|
try {
|
|
1424
|
-
await (0,
|
|
1479
|
+
await (0, import_promises3.rm)(dir, { recursive: true, force: true });
|
|
1425
1480
|
} catch {
|
|
1426
1481
|
}
|
|
1427
1482
|
}
|
|
1428
1483
|
};
|
|
1429
1484
|
} catch (err) {
|
|
1430
1485
|
try {
|
|
1431
|
-
await (0,
|
|
1486
|
+
await (0, import_promises3.rm)(dir, { recursive: true, force: true });
|
|
1432
1487
|
} catch {
|
|
1433
1488
|
}
|
|
1434
1489
|
throw err;
|
|
@@ -1457,7 +1512,7 @@ async function loadFfmpegFactory() {
|
|
|
1457
1512
|
const code = err?.code;
|
|
1458
1513
|
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
|
|
1459
1514
|
throw new VisualAIVideoError(
|
|
1460
|
-
"
|
|
1515
|
+
"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
1516
|
);
|
|
1462
1517
|
}
|
|
1463
1518
|
throw new VisualAIVideoError(
|
|
@@ -1502,7 +1557,7 @@ async function loadFfmpegFactory() {
|
|
|
1502
1557
|
}
|
|
1503
1558
|
async function probeDurationSeconds(videoPath) {
|
|
1504
1559
|
const ffmpeg = await loadFfmpegFactory();
|
|
1505
|
-
return new Promise((
|
|
1560
|
+
return new Promise((resolve2, reject) => {
|
|
1506
1561
|
let settled = false;
|
|
1507
1562
|
const finish = (fn) => {
|
|
1508
1563
|
if (settled) return;
|
|
@@ -1539,7 +1594,7 @@ async function probeDurationSeconds(videoPath) {
|
|
|
1539
1594
|
return;
|
|
1540
1595
|
}
|
|
1541
1596
|
finish(() => {
|
|
1542
|
-
|
|
1597
|
+
resolve2(duration);
|
|
1543
1598
|
});
|
|
1544
1599
|
});
|
|
1545
1600
|
});
|
|
@@ -1571,10 +1626,10 @@ async function extractFrames(videoPath, options = {}) {
|
|
|
1571
1626
|
`Video duration ${durationSeconds.toFixed(2)}s exceeds limit of ${maxDurationSeconds}s. Pass { maxDurationSeconds: N } to override, or trim the source video.`
|
|
1572
1627
|
);
|
|
1573
1628
|
}
|
|
1574
|
-
const outputDir = await (0,
|
|
1629
|
+
const outputDir = await (0, import_promises3.mkdtemp)((0, import_node_path3.join)((0, import_node_os.tmpdir)(), "visual-ai-frames-"));
|
|
1575
1630
|
try {
|
|
1576
1631
|
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((
|
|
1632
|
+
await new Promise((resolve2, reject) => {
|
|
1578
1633
|
let settled = false;
|
|
1579
1634
|
const cmd = ffmpeg(videoPath);
|
|
1580
1635
|
const finish = (fn) => {
|
|
@@ -1596,9 +1651,9 @@ async function extractFrames(videoPath, options = {}) {
|
|
|
1596
1651
|
);
|
|
1597
1652
|
});
|
|
1598
1653
|
}, FFMPEG_RUN_TIMEOUT_MS);
|
|
1599
|
-
cmd.outputOptions(["-vf", filter, "-vframes", String(maxFrames), "-q:v", "3"]).output((0,
|
|
1654
|
+
cmd.outputOptions(["-vf", filter, "-vframes", String(maxFrames), "-q:v", "3"]).output((0, import_node_path3.join)(outputDir, "frame-%04d.jpg")).on("end", () => {
|
|
1600
1655
|
finish(() => {
|
|
1601
|
-
|
|
1656
|
+
resolve2();
|
|
1602
1657
|
});
|
|
1603
1658
|
}).on("error", (err) => {
|
|
1604
1659
|
finish(() => {
|
|
@@ -1606,7 +1661,7 @@ async function extractFrames(videoPath, options = {}) {
|
|
|
1606
1661
|
});
|
|
1607
1662
|
}).run();
|
|
1608
1663
|
});
|
|
1609
|
-
const files = (await (0,
|
|
1664
|
+
const files = (await (0, import_promises3.readdir)(outputDir)).filter((name) => name.endsWith(".jpg")).sort();
|
|
1610
1665
|
if (files.length === 0) {
|
|
1611
1666
|
throw new VisualAIVideoError(
|
|
1612
1667
|
"No frames could be extracted from the video. The source may be corrupt or empty."
|
|
@@ -1614,7 +1669,7 @@ async function extractFrames(videoPath, options = {}) {
|
|
|
1614
1669
|
}
|
|
1615
1670
|
const frames = await Promise.all(
|
|
1616
1671
|
files.map(async (name, index) => {
|
|
1617
|
-
const data = await (0,
|
|
1672
|
+
const data = await (0, import_promises3.readFile)((0, import_node_path3.join)(outputDir, name));
|
|
1618
1673
|
const timestampSeconds = Math.min(durationSeconds, (index + 0.5) / fps);
|
|
1619
1674
|
let cachedBase64;
|
|
1620
1675
|
return {
|
|
@@ -1634,7 +1689,7 @@ async function extractFrames(videoPath, options = {}) {
|
|
|
1634
1689
|
return { frames, durationSeconds };
|
|
1635
1690
|
} finally {
|
|
1636
1691
|
try {
|
|
1637
|
-
await (0,
|
|
1692
|
+
await (0, import_promises3.rm)(outputDir, { recursive: true, force: true });
|
|
1638
1693
|
} catch {
|
|
1639
1694
|
}
|
|
1640
1695
|
}
|
|
@@ -1670,6 +1725,7 @@ async function normalizeMedia(input, videoOptions) {
|
|
|
1670
1725
|
const { path, cleanup } = await resolveVideoToPath(input);
|
|
1671
1726
|
try {
|
|
1672
1727
|
const { frames, durationSeconds } = await extractFrames(path, videoOptions);
|
|
1728
|
+
await saveDebugFrames(frames);
|
|
1673
1729
|
return { kind: "video", frames, durationSeconds };
|
|
1674
1730
|
} finally {
|
|
1675
1731
|
try {
|