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/dist/index.js
CHANGED
|
@@ -1241,10 +1241,65 @@ async function normalizeImage(input) {
|
|
|
1241
1241
|
};
|
|
1242
1242
|
}
|
|
1243
1243
|
|
|
1244
|
+
// src/core/debug-frames.ts
|
|
1245
|
+
import { randomBytes } from "crypto";
|
|
1246
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
1247
|
+
import { join, resolve } from "path";
|
|
1248
|
+
var DEBUG_FRAMES_ENV = "VISUAL_AI_DEBUG_FRAMES";
|
|
1249
|
+
var DEBUG_FRAMES_DIR_ENV = "VISUAL_AI_DEBUG_FRAMES_DIR";
|
|
1250
|
+
var DEFAULT_DIR_NAME = "visual-ai-debug-frames";
|
|
1251
|
+
function isEnabled(env) {
|
|
1252
|
+
const raw = env[DEBUG_FRAMES_ENV];
|
|
1253
|
+
if (raw === void 0 || raw === "") return false;
|
|
1254
|
+
const lower = raw.toLowerCase();
|
|
1255
|
+
return lower === "true" || lower === "1";
|
|
1256
|
+
}
|
|
1257
|
+
function timestampSlug(date) {
|
|
1258
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
1259
|
+
}
|
|
1260
|
+
function paddedIndex(value, total) {
|
|
1261
|
+
const width = Math.max(2, String(total - 1).length);
|
|
1262
|
+
return String(value).padStart(width, "0");
|
|
1263
|
+
}
|
|
1264
|
+
function extensionFromMimeType(mimeType) {
|
|
1265
|
+
if (mimeType === "image/png") return ".png";
|
|
1266
|
+
if (mimeType === "image/webp") return ".webp";
|
|
1267
|
+
return ".jpg";
|
|
1268
|
+
}
|
|
1269
|
+
async function saveDebugFrames(frames, env = process.env) {
|
|
1270
|
+
if (!isEnabled(env)) return void 0;
|
|
1271
|
+
if (frames.length === 0) return void 0;
|
|
1272
|
+
const baseDir = env[DEBUG_FRAMES_DIR_ENV]?.trim() || DEFAULT_DIR_NAME;
|
|
1273
|
+
const runDir = resolve(baseDir, `${timestampSlug(/* @__PURE__ */ new Date())}-${randomBytes(3).toString("hex")}`);
|
|
1274
|
+
try {
|
|
1275
|
+
await mkdir(runDir, { recursive: true });
|
|
1276
|
+
await Promise.all(
|
|
1277
|
+
frames.map((frame) => {
|
|
1278
|
+
const idx = paddedIndex(frame.index, frames.length);
|
|
1279
|
+
const ts = frame.timestampSeconds.toFixed(2);
|
|
1280
|
+
const ext = extensionFromMimeType(frame.mimeType);
|
|
1281
|
+
const filename = `frame-${idx}-t${ts}s${ext}`;
|
|
1282
|
+
return writeFile(join(runDir, filename), frame.data);
|
|
1283
|
+
})
|
|
1284
|
+
);
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
process.stderr.write(
|
|
1287
|
+
`[visual-ai-assertions] warning: failed to save debug frames to ${runDir}: ${err instanceof Error ? err.message : String(err)}
|
|
1288
|
+
`
|
|
1289
|
+
);
|
|
1290
|
+
return void 0;
|
|
1291
|
+
}
|
|
1292
|
+
process.stderr.write(
|
|
1293
|
+
`[visual-ai-assertions] Saved ${frames.length} debug frame(s) to ${runDir}
|
|
1294
|
+
`
|
|
1295
|
+
);
|
|
1296
|
+
return runDir;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1244
1299
|
// src/core/video.ts
|
|
1245
|
-
import { mkdtemp, readFile as readFile2, readdir, rm, writeFile } from "fs/promises";
|
|
1300
|
+
import { mkdtemp, readFile as readFile2, readdir, rm, writeFile as writeFile2 } from "fs/promises";
|
|
1246
1301
|
import { tmpdir } from "os";
|
|
1247
|
-
import { extname as extname2, join } from "path";
|
|
1302
|
+
import { extname as extname2, join as join2 } from "path";
|
|
1248
1303
|
var FRAME_MAX_DIMENSION = 1568;
|
|
1249
1304
|
var DEFAULT_FPS = 1;
|
|
1250
1305
|
var DEFAULT_MAX_FRAMES = 10;
|
|
@@ -1343,11 +1398,11 @@ async function resolveVideoToPath(input) {
|
|
|
1343
1398
|
return writeBufferToTemp(buf, mimeType);
|
|
1344
1399
|
}
|
|
1345
1400
|
async function writeBufferToTemp(data, mimeType) {
|
|
1346
|
-
const dir = await mkdtemp(
|
|
1401
|
+
const dir = await mkdtemp(join2(tmpdir(), "visual-ai-video-"));
|
|
1347
1402
|
try {
|
|
1348
1403
|
const ext = extensionFor(mimeType);
|
|
1349
|
-
const path =
|
|
1350
|
-
await
|
|
1404
|
+
const path = join2(dir, `input${ext}`);
|
|
1405
|
+
await writeFile2(path, data);
|
|
1351
1406
|
return {
|
|
1352
1407
|
path,
|
|
1353
1408
|
mimeType,
|
|
@@ -1389,7 +1444,7 @@ async function loadFfmpegFactory() {
|
|
|
1389
1444
|
const code = err?.code;
|
|
1390
1445
|
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
|
|
1391
1446
|
throw new VisualAIVideoError(
|
|
1392
|
-
"
|
|
1447
|
+
"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
1448
|
);
|
|
1394
1449
|
}
|
|
1395
1450
|
throw new VisualAIVideoError(
|
|
@@ -1434,7 +1489,7 @@ async function loadFfmpegFactory() {
|
|
|
1434
1489
|
}
|
|
1435
1490
|
async function probeDurationSeconds(videoPath) {
|
|
1436
1491
|
const ffmpeg = await loadFfmpegFactory();
|
|
1437
|
-
return new Promise((
|
|
1492
|
+
return new Promise((resolve2, reject) => {
|
|
1438
1493
|
let settled = false;
|
|
1439
1494
|
const finish = (fn) => {
|
|
1440
1495
|
if (settled) return;
|
|
@@ -1471,7 +1526,7 @@ async function probeDurationSeconds(videoPath) {
|
|
|
1471
1526
|
return;
|
|
1472
1527
|
}
|
|
1473
1528
|
finish(() => {
|
|
1474
|
-
|
|
1529
|
+
resolve2(duration);
|
|
1475
1530
|
});
|
|
1476
1531
|
});
|
|
1477
1532
|
});
|
|
@@ -1503,10 +1558,10 @@ async function extractFrames(videoPath, options = {}) {
|
|
|
1503
1558
|
`Video duration ${durationSeconds.toFixed(2)}s exceeds limit of ${maxDurationSeconds}s. Pass { maxDurationSeconds: N } to override, or trim the source video.`
|
|
1504
1559
|
);
|
|
1505
1560
|
}
|
|
1506
|
-
const outputDir = await mkdtemp(
|
|
1561
|
+
const outputDir = await mkdtemp(join2(tmpdir(), "visual-ai-frames-"));
|
|
1507
1562
|
try {
|
|
1508
1563
|
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((
|
|
1564
|
+
await new Promise((resolve2, reject) => {
|
|
1510
1565
|
let settled = false;
|
|
1511
1566
|
const cmd = ffmpeg(videoPath);
|
|
1512
1567
|
const finish = (fn) => {
|
|
@@ -1528,9 +1583,9 @@ async function extractFrames(videoPath, options = {}) {
|
|
|
1528
1583
|
);
|
|
1529
1584
|
});
|
|
1530
1585
|
}, FFMPEG_RUN_TIMEOUT_MS);
|
|
1531
|
-
cmd.outputOptions(["-vf", filter, "-vframes", String(maxFrames), "-q:v", "3"]).output(
|
|
1586
|
+
cmd.outputOptions(["-vf", filter, "-vframes", String(maxFrames), "-q:v", "3"]).output(join2(outputDir, "frame-%04d.jpg")).on("end", () => {
|
|
1532
1587
|
finish(() => {
|
|
1533
|
-
|
|
1588
|
+
resolve2();
|
|
1534
1589
|
});
|
|
1535
1590
|
}).on("error", (err) => {
|
|
1536
1591
|
finish(() => {
|
|
@@ -1546,7 +1601,7 @@ async function extractFrames(videoPath, options = {}) {
|
|
|
1546
1601
|
}
|
|
1547
1602
|
const frames = await Promise.all(
|
|
1548
1603
|
files.map(async (name, index) => {
|
|
1549
|
-
const data = await readFile2(
|
|
1604
|
+
const data = await readFile2(join2(outputDir, name));
|
|
1550
1605
|
const timestampSeconds = Math.min(durationSeconds, (index + 0.5) / fps);
|
|
1551
1606
|
let cachedBase64;
|
|
1552
1607
|
return {
|
|
@@ -1602,6 +1657,7 @@ async function normalizeMedia(input, videoOptions) {
|
|
|
1602
1657
|
const { path, cleanup } = await resolveVideoToPath(input);
|
|
1603
1658
|
try {
|
|
1604
1659
|
const { frames, durationSeconds } = await extractFrames(path, videoOptions);
|
|
1660
|
+
await saveDebugFrames(frames);
|
|
1605
1661
|
return { kind: "video", frames, durationSeconds };
|
|
1606
1662
|
} finally {
|
|
1607
1663
|
try {
|