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 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 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
@@ -1309,10 +1309,65 @@ async function normalizeImage(input) {
1309
1309
  };
1310
1310
  }
1311
1311
 
1312
- // src/core/video.ts
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, import_node_path2.extname)(filePath).toLowerCase();
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, import_promises2.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "visual-ai-video-"));
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, import_node_path2.join)(dir, `input${ext}`);
1418
- await (0, import_promises2.writeFile)(path, data);
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, import_promises2.rm)(dir, { recursive: true, force: true });
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, import_promises2.rm)(dir, { recursive: true, force: true });
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
- "Video support requires fluent-ffmpeg. Install it with: pnpm add -D fluent-ffmpeg @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe @types/fluent-ffmpeg"
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((resolve, reject) => {
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
- resolve(duration);
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, import_promises2.mkdtemp)((0, import_node_path2.join)((0, import_node_os.tmpdir)(), "visual-ai-frames-"));
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((resolve, reject) => {
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, import_node_path2.join)(outputDir, "frame-%04d.jpg")).on("end", () => {
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
- resolve();
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, import_promises2.readdir)(outputDir)).filter((name) => name.endsWith(".jpg")).sort();
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, import_promises2.readFile)((0, import_node_path2.join)(outputDir, name));
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, import_promises2.rm)(outputDir, { recursive: true, force: true });
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 {