visual-ai-assertions 0.13.0 → 0.14.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
@@ -346,6 +346,31 @@ How it works: the library samples frames with ffmpeg and sends them to the provi
346
346
 
347
347
  **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.
348
348
 
349
+ ### Pre-sampled Frames
350
+
351
+ When you already have an array of screenshots — or can't pass a video file — hand `check()` and `ask()` a `FramesInput` (`{ frames, fps? }`) instead of a `MediaInput`. Each frame is a plain `ImageInput` (buffer, data URL, base64, file path, or URL) or a `{ image, timestampSeconds }` pair. Frames are treated as the same chronological timeline a sampled video produces — no ffmpeg is loaded on this path.
352
+
353
+ ```typescript
354
+ const frames = [await page.screenshot(), await page.screenshot()];
355
+
356
+ const result = await ai.check({ frames }, ['A success toast with text "Saved" appears']);
357
+ console.log(result.frames);
358
+ // { count: 2, timestampsSeconds: [0, 1], durationSeconds: 1 }
359
+
360
+ // Control timestamps: give an explicit fps, or per-frame timestampSeconds
361
+ await ai.ask(
362
+ {
363
+ frames: [
364
+ { image: before, timestampSeconds: 0 },
365
+ { image: after, timestampSeconds: 2.5 },
366
+ ],
367
+ },
368
+ "What changed between the frames?",
369
+ );
370
+ ```
371
+
372
+ Timestamps for bare frames are derived as `index / fps` (default `fps` is `1`); a per-frame `timestampSeconds` overrides that. The frame count is subject to the same 60-frame hard cap as video sampling, and the `video` sampling option is ignored for this path.
373
+
349
374
  ### Formatting & Assertion Helpers
350
375
 
351
376
  ```typescript
package/dist/index.cjs CHANGED
@@ -1773,7 +1773,57 @@ function isVideoInput(input) {
1773
1773
  }
1774
1774
  return false;
1775
1775
  }
1776
+ function isFramesInput(input) {
1777
+ return typeof input === "object" && input !== null && !Buffer.isBuffer(input) && !(input instanceof Uint8Array) && Array.isArray(input.frames);
1778
+ }
1779
+ function isTimestampedFrameInput(frame) {
1780
+ return typeof frame === "object" && !Buffer.isBuffer(frame) && !(frame instanceof Uint8Array) && "image" in frame;
1781
+ }
1782
+ async function normalizeFrames(input) {
1783
+ const rawFrames = input.frames;
1784
+ const fps = input.fps ?? DEFAULT_FPS;
1785
+ if (rawFrames.length === 0) {
1786
+ throw new VisualAIVideoError("frames must be a non-empty array of image inputs");
1787
+ }
1788
+ if (rawFrames.length > MAX_FRAMES_HARD_CAP) {
1789
+ throw new VisualAIVideoError(
1790
+ `frames length ${rawFrames.length} exceeds the hard cap of ${MAX_FRAMES_HARD_CAP}. Pass fewer frames or open an issue if you need a larger limit.`
1791
+ );
1792
+ }
1793
+ if (!Number.isFinite(fps) || fps <= 0) {
1794
+ throw new VisualAIVideoError(`Invalid fps: ${fps}. Must be a finite number > 0.`);
1795
+ }
1796
+ const frames = await Promise.all(
1797
+ rawFrames.map(async (raw, index) => {
1798
+ const timestamped = isTimestampedFrameInput(raw);
1799
+ const imageInput = timestamped ? raw.image : raw;
1800
+ const explicit = timestamped ? raw.timestampSeconds : void 0;
1801
+ const timestampSeconds = explicit ?? index / fps;
1802
+ if (!Number.isFinite(timestampSeconds) || timestampSeconds < 0) {
1803
+ throw new VisualAIVideoError(
1804
+ `Invalid timestampSeconds for frame ${index}: ${String(timestampSeconds)}. Must be a finite number >= 0.`
1805
+ );
1806
+ }
1807
+ const image = await normalizeImage(imageInput);
1808
+ return {
1809
+ data: image.data,
1810
+ mimeType: image.mimeType,
1811
+ get base64() {
1812
+ return image.base64;
1813
+ },
1814
+ timestampSeconds,
1815
+ index
1816
+ };
1817
+ })
1818
+ );
1819
+ const durationSeconds = frames.reduce((max, f) => Math.max(max, f.timestampSeconds), 0);
1820
+ await saveDebugFrames(frames);
1821
+ return { kind: "video", frames, durationSeconds };
1822
+ }
1776
1823
  async function normalizeMedia(input, videoOptions) {
1824
+ if (isFramesInput(input)) {
1825
+ return normalizeFrames(input);
1826
+ }
1777
1827
  if (isVideoInput(input)) {
1778
1828
  const { path, cleanup } = await resolveVideoToPath(input);
1779
1829
  try {
@@ -1853,8 +1903,12 @@ var AskResultSchema = import_zod.z.object({
1853
1903
  /**
1854
1904
  * For video inputs, the indices of frames the model relied on to answer.
1855
1905
  * Indices are 0-based and refer to entries in `frames.timestampsSeconds`.
1906
+ *
1907
+ * Nullable because providers with strict structured-output schemas (e.g. OpenAI)
1908
+ * must mark every field required and represent "no value" as `null` rather than
1909
+ * omitting the key, even for image inputs that were never asked to populate it.
1856
1910
  */
1857
- frameReferences: import_zod.z.array(import_zod.z.number().int().nonnegative()).optional(),
1911
+ frameReferences: import_zod.z.array(import_zod.z.number().int().nonnegative()).nullable().optional(),
1858
1912
  usage: UsageInfoSchema.optional()
1859
1913
  });
1860
1914
 
@@ -1906,7 +1960,11 @@ function parseCheckResponse(raw) {
1906
1960
  return reconcileCheckResult(result);
1907
1961
  }
1908
1962
  function parseAskResponse(raw) {
1909
- return parseResponse(raw, AskResponseSchema);
1963
+ const result = parseResponse(raw, AskResponseSchema);
1964
+ return {
1965
+ ...result,
1966
+ frameReferences: result.frameReferences ?? void 0
1967
+ };
1910
1968
  }
1911
1969
  function parseCompareResponse(raw) {
1912
1970
  return parseResponse(raw, CompareResponseSchema);