pi-multimodal-proxy 1.15.0 → 1.16.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/CHANGELOG.md +20 -0
- package/README.md +22 -4
- package/extensions/__tests__/internal.test.ts +270 -3
- package/extensions/internal.ts +357 -7
- package/extensions/vision-proxy.ts +439 -40
- package/package.json +1 -1
package/extensions/internal.ts
CHANGED
|
@@ -85,6 +85,15 @@ export interface VisionConfig {
|
|
|
85
85
|
// "youtube:player_client=web_safari,web").
|
|
86
86
|
ytdlpCookiesFromBrowser: string;
|
|
87
87
|
ytdlpExtractorArgs: string;
|
|
88
|
+
// 1.16.0 — reliability (borrowed from atlas-vision-mcp): transient-error
|
|
89
|
+
// retry budget for vision calls, upload downscale thresholds, and an
|
|
90
|
+
// optional fallback vision model used when the primary fails after
|
|
91
|
+
// retries. Both fallback halves must be set together or neither.
|
|
92
|
+
retryMax: number;
|
|
93
|
+
maxUploadDim: number;
|
|
94
|
+
maxUploadBytes: number;
|
|
95
|
+
fallbackProvider?: string;
|
|
96
|
+
fallbackModelId?: string;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
export interface ImageMeta {
|
|
@@ -712,6 +721,12 @@ export const DEFAULT_CONFIG: VisionConfig = {
|
|
|
712
721
|
},
|
|
713
722
|
ytdlpCookiesFromBrowser: "",
|
|
714
723
|
ytdlpExtractorArgs: "",
|
|
724
|
+
// 1.16.0 reliability defaults: 2 retries with exponential backoff+jitter;
|
|
725
|
+
// downscale uploads larger than 2048px on the long edge or 5 MB raw bytes
|
|
726
|
+
// (base64 inflates ~4/3, so 5 MB ≈ 6.7 MB payload — within provider limits).
|
|
727
|
+
retryMax: 2,
|
|
728
|
+
maxUploadDim: 2048,
|
|
729
|
+
maxUploadBytes: 5 * 1024 * 1024,
|
|
715
730
|
};
|
|
716
731
|
|
|
717
732
|
// ── Persistent file storage ────────────────────────────────────────────────
|
|
@@ -739,6 +754,11 @@ const PERSISTED_CONFIG_KEYS = new Set([
|
|
|
739
754
|
"pathDetection",
|
|
740
755
|
"ytdlpCookiesFromBrowser",
|
|
741
756
|
"ytdlpExtractorArgs",
|
|
757
|
+
"retryMax",
|
|
758
|
+
"maxUploadDim",
|
|
759
|
+
"maxUploadBytes",
|
|
760
|
+
"fallbackProvider",
|
|
761
|
+
"fallbackModelId",
|
|
742
762
|
]);
|
|
743
763
|
|
|
744
764
|
/** Read config from the persistent file. Returns empty object on any failure. */
|
|
@@ -807,6 +827,50 @@ export function parseAllowHomeEnv(raw: string | undefined): boolean | undefined
|
|
|
807
827
|
return undefined;
|
|
808
828
|
}
|
|
809
829
|
|
|
830
|
+
// ── 1.16.0 reliability env parsers ──────────────────────────────────────────
|
|
831
|
+
// Shared by readEnvOverrides (apply) and envFlags (presence), so an invalid
|
|
832
|
+
// value can never lock its command without producing an effective override.
|
|
833
|
+
|
|
834
|
+
/** Retry budget 0..5; anything else is no override. */
|
|
835
|
+
export function parseRetryMaxEnv(raw: string | undefined): number | undefined {
|
|
836
|
+
if (!raw) return undefined;
|
|
837
|
+
const n = Number.parseInt(raw, 10);
|
|
838
|
+
return Number.isFinite(n) && n >= 0 && n <= 5 ? n : undefined;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Upload long-edge threshold: 0 (downscaling disabled entirely), or
|
|
843
|
+
* 512..8192 px; anything else is no override.
|
|
844
|
+
*/
|
|
845
|
+
export function parseUploadDimEnv(raw: string | undefined): number | undefined {
|
|
846
|
+
if (!raw) return undefined;
|
|
847
|
+
const n = Number.parseInt(raw, 10);
|
|
848
|
+
return Number.isFinite(n) && (n === 0 || (n >= 512 && n <= 8192)) ? n : undefined;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Upload byte budget 0.5..20 MB → bytes; anything else is no override. */
|
|
852
|
+
export function parseUploadMbEnv(raw: string | undefined): number | undefined {
|
|
853
|
+
if (!raw) return undefined;
|
|
854
|
+
const n = parseFloat(raw);
|
|
855
|
+
return Number.isFinite(n) && n >= 0.5 && n <= 20 ? Math.round(n * 1024 * 1024) : undefined;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Fallback vision model: a clear sentinel ("", "none", "off"), a valid
|
|
860
|
+
* provider/model-id, or no override.
|
|
861
|
+
*/
|
|
862
|
+
export function parseFallbackModelEnv(
|
|
863
|
+
raw: string | undefined,
|
|
864
|
+
): { provider: string; modelId: string; clear?: false } | { clear: true } | undefined {
|
|
865
|
+
if (raw === undefined) return undefined;
|
|
866
|
+
const trimmed = raw.trim();
|
|
867
|
+
if (trimmed === "" || trimmed.toLowerCase() === "none" || trimmed.toLowerCase() === "off") {
|
|
868
|
+
return { clear: true };
|
|
869
|
+
}
|
|
870
|
+
const parsed = parseModelString(trimmed);
|
|
871
|
+
return parsed ? { provider: parsed.provider, modelId: parsed.modelId } : undefined;
|
|
872
|
+
}
|
|
873
|
+
|
|
810
874
|
export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<VisionConfig> {
|
|
811
875
|
const overrides: Partial<VisionConfig> = {};
|
|
812
876
|
const modeEnv = env.PI_VISION_PROXY_MODE;
|
|
@@ -883,10 +947,29 @@ export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<
|
|
|
883
947
|
if (cookiesEnv !== undefined) overrides.ytdlpCookiesFromBrowser = sanitizeYtdlpCookiesFromBrowser(cookiesEnv);
|
|
884
948
|
const extractorArgsEnv = env.PI_VISION_PROXY_YTDLP_EXTRACTOR_ARGS;
|
|
885
949
|
if (extractorArgsEnv !== undefined) overrides.ytdlpExtractorArgs = sanitizeYtdlpExtractorArgs(extractorArgsEnv);
|
|
950
|
+
// 1.16.0 reliability env overrides (shared parsers keep envFlags' presence
|
|
951
|
+
// detection consistent with what actually applies here)
|
|
952
|
+
const retryEnv = parseRetryMaxEnv(env.PI_VISION_PROXY_RETRY_MAX);
|
|
953
|
+
if (retryEnv !== undefined) overrides.retryMax = retryEnv;
|
|
954
|
+
const uploadDimEnv = parseUploadDimEnv(env.PI_VISION_PROXY_MAX_UPLOAD_DIM);
|
|
955
|
+
if (uploadDimEnv !== undefined) overrides.maxUploadDim = uploadDimEnv;
|
|
956
|
+
const uploadMbEnv = parseUploadMbEnv(env.PI_VISION_PROXY_MAX_UPLOAD_MB);
|
|
957
|
+
if (uploadMbEnv !== undefined) overrides.maxUploadBytes = uploadMbEnv;
|
|
958
|
+
const fallbackModelEnv = parseFallbackModelEnv(env.PI_VISION_PROXY_FALLBACK_MODEL);
|
|
959
|
+
if (fallbackModelEnv) {
|
|
960
|
+
if (fallbackModelEnv.clear) {
|
|
961
|
+
// Undefined values delete the keys when spread over defaults.
|
|
962
|
+
overrides.fallbackProvider = undefined;
|
|
963
|
+
overrides.fallbackModelId = undefined;
|
|
964
|
+
} else {
|
|
965
|
+
overrides.fallbackProvider = fallbackModelEnv.provider;
|
|
966
|
+
overrides.fallbackModelId = fallbackModelEnv.modelId;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
886
969
|
return overrides;
|
|
887
970
|
}
|
|
888
971
|
|
|
889
|
-
export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean; model: boolean; context: boolean; tool: boolean; maxImagesPerCall: boolean; maxBatch: boolean; cacheSize: boolean; videoModel: boolean; allowedProviders: boolean; allowHome: boolean; allowedFolders: boolean; statusLine: boolean; pathDetection: boolean; ytdlpCookies: boolean; ytdlpExtractorArgs: boolean } {
|
|
972
|
+
export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean; model: boolean; context: boolean; tool: boolean; maxImagesPerCall: boolean; maxBatch: boolean; cacheSize: boolean; videoModel: boolean; allowedProviders: boolean; allowHome: boolean; allowedFolders: boolean; statusLine: boolean; pathDetection: boolean; ytdlpCookies: boolean; ytdlpExtractorArgs: boolean; retryMax: boolean; maxUpload: boolean; fallbackModel: boolean } {
|
|
890
973
|
return {
|
|
891
974
|
mode: Boolean(env.PI_VISION_PROXY_MODE),
|
|
892
975
|
model: Boolean(env.PI_VISION_PROXY_MODEL),
|
|
@@ -908,6 +991,13 @@ export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean;
|
|
|
908
991
|
pathDetection: env.PI_VISION_PROXY_PATH_DETECTION === "on" || env.PI_VISION_PROXY_PATH_DETECTION === "off",
|
|
909
992
|
ytdlpCookies: env.PI_VISION_PROXY_YTDLP_COOKIES_FROM_BROWSER !== undefined,
|
|
910
993
|
ytdlpExtractorArgs: env.PI_VISION_PROXY_YTDLP_EXTRACTOR_ARGS !== undefined,
|
|
994
|
+
// 1.16.0 — only a value readEnvOverrides actually applies counts as an
|
|
995
|
+
// override; an invalid value must not lock the command.
|
|
996
|
+
retryMax: parseRetryMaxEnv(env.PI_VISION_PROXY_RETRY_MAX) !== undefined,
|
|
997
|
+
maxUpload:
|
|
998
|
+
parseUploadDimEnv(env.PI_VISION_PROXY_MAX_UPLOAD_DIM) !== undefined ||
|
|
999
|
+
parseUploadMbEnv(env.PI_VISION_PROXY_MAX_UPLOAD_MB) !== undefined,
|
|
1000
|
+
fallbackModel: parseFallbackModelEnv(env.PI_VISION_PROXY_FALLBACK_MODEL) !== undefined,
|
|
911
1001
|
};
|
|
912
1002
|
}
|
|
913
1003
|
|
|
@@ -1120,6 +1210,40 @@ export function sanitize(config: VisionConfig): VisionConfig {
|
|
|
1120
1210
|
// 1.12.1 yt-dlp tuning fields
|
|
1121
1211
|
safe.ytdlpCookiesFromBrowser = sanitizeYtdlpCookiesFromBrowser(safe.ytdlpCookiesFromBrowser);
|
|
1122
1212
|
safe.ytdlpExtractorArgs = sanitizeYtdlpExtractorArgs(safe.ytdlpExtractorArgs);
|
|
1213
|
+
// 1.16.0 reliability fields
|
|
1214
|
+
if (!Number.isFinite(safe.retryMax) || safe.retryMax < 0 || safe.retryMax > 5) {
|
|
1215
|
+
safe.retryMax = DEFAULT_CONFIG.retryMax;
|
|
1216
|
+
}
|
|
1217
|
+
safe.retryMax = Math.round(safe.retryMax);
|
|
1218
|
+
// maxUploadDim 0 = upload downscaling disabled entirely (review: "off"
|
|
1219
|
+
// previously clamped to 8192 px and still transformed oversized uploads).
|
|
1220
|
+
if (
|
|
1221
|
+
!Number.isFinite(safe.maxUploadDim) ||
|
|
1222
|
+
!(safe.maxUploadDim === 0 || (safe.maxUploadDim >= 512 && safe.maxUploadDim <= 8192))
|
|
1223
|
+
) {
|
|
1224
|
+
safe.maxUploadDim = DEFAULT_CONFIG.maxUploadDim;
|
|
1225
|
+
}
|
|
1226
|
+
safe.maxUploadDim = Math.round(safe.maxUploadDim);
|
|
1227
|
+
if (
|
|
1228
|
+
!Number.isFinite(safe.maxUploadBytes) ||
|
|
1229
|
+
safe.maxUploadBytes < 512 * 1024 ||
|
|
1230
|
+
safe.maxUploadBytes > 20 * 1024 * 1024
|
|
1231
|
+
) {
|
|
1232
|
+
safe.maxUploadBytes = DEFAULT_CONFIG.maxUploadBytes;
|
|
1233
|
+
}
|
|
1234
|
+
safe.maxUploadBytes = Math.round(safe.maxUploadBytes);
|
|
1235
|
+
// Fallback model: canonicalize, validate, and require both halves together.
|
|
1236
|
+
// Both halves must be real strings before the pattern checks — RegExp.test()
|
|
1237
|
+
// coerces its argument, so a numeric modelId (e.g. 123) could otherwise pass.
|
|
1238
|
+
if (typeof safe.fallbackProvider !== "string") delete safe.fallbackProvider;
|
|
1239
|
+
else safe.fallbackProvider = canonicalProvider(safe.fallbackProvider);
|
|
1240
|
+
if (typeof safe.fallbackModelId !== "string") delete safe.fallbackModelId;
|
|
1241
|
+
if (safe.fallbackProvider !== undefined && !PROVIDER_PATTERN.test(safe.fallbackProvider)) delete safe.fallbackProvider;
|
|
1242
|
+
if (safe.fallbackModelId !== undefined && !MODEL_ID_PATTERN.test(safe.fallbackModelId)) delete safe.fallbackModelId;
|
|
1243
|
+
if (!safe.fallbackProvider || !safe.fallbackModelId) {
|
|
1244
|
+
delete safe.fallbackProvider;
|
|
1245
|
+
delete safe.fallbackModelId;
|
|
1246
|
+
}
|
|
1123
1247
|
return safe;
|
|
1124
1248
|
}
|
|
1125
1249
|
|
|
@@ -1289,6 +1413,100 @@ export function hasConsent(
|
|
|
1289
1413
|
return Boolean(provider && allowedProviders?.includes(canonicalProvider(provider)));
|
|
1290
1414
|
}
|
|
1291
1415
|
|
|
1416
|
+
// ── Vision-call retry & backoff (1.16.0) ───────────────────────────────────
|
|
1417
|
+
|
|
1418
|
+
/** HTTP statuses worth retrying (rate limit + transient server/network). */
|
|
1419
|
+
const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
1420
|
+
|
|
1421
|
+
/** Statuses that retrying can never fix — fall through to the next model. */
|
|
1422
|
+
const NON_TRANSIENT_STATUSES = new Set([400, 401, 403, 404, 405, 413, 422]);
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Message patterns of transient provider/network failures. Written against
|
|
1426
|
+
* common pi-ai / undici error text; the status-code checks above take priority
|
|
1427
|
+
* when the error carries one.
|
|
1428
|
+
*/
|
|
1429
|
+
const TRANSIENT_MESSAGE_RE =
|
|
1430
|
+
/\b(429|too many requests|rate[ _-]?limit|overloaded|econnreset|econnrefused|etimedout|econnaborted|enotfound|eai_again|epipe|enotfound|timeout|timed out|fetch failed|network error|socket hang up|internal server error|bad gateway|service unavailable|gateway timeout)\b/i;
|
|
1431
|
+
|
|
1432
|
+
/** Node/DOM abort signals — never retried, never failed over. */
|
|
1433
|
+
export function isAbortError(err: unknown): boolean {
|
|
1434
|
+
if (!err || typeof err !== "object") return false;
|
|
1435
|
+
const e = err as { name?: unknown; code?: unknown };
|
|
1436
|
+
return e.name === "AbortError" || e.code === "ABORT_ERR";
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Whether a vision-call failure is worth retrying: rate limits, transient 5xx,
|
|
1441
|
+
* and network hiccups qualify; auth/permission/payload errors and user aborts
|
|
1442
|
+
* do not. Defensive about error shape — providers throw plain Errors,
|
|
1443
|
+
* status-carrying API errors, undici TypeErrors, and occasionally strings.
|
|
1444
|
+
*/
|
|
1445
|
+
export function isTransientVisionError(err: unknown): boolean {
|
|
1446
|
+
if (!err) return false;
|
|
1447
|
+
if (typeof err === "string") return TRANSIENT_MESSAGE_RE.test(err);
|
|
1448
|
+
if (isAbortError(err)) return false;
|
|
1449
|
+
const e = err as { status?: unknown; statusCode?: unknown; code?: unknown; message?: unknown };
|
|
1450
|
+
for (const key of ["status", "statusCode"] as const) {
|
|
1451
|
+
const v = e[key];
|
|
1452
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
1453
|
+
if (TRANSIENT_STATUSES.has(v)) return true;
|
|
1454
|
+
if (NON_TRANSIENT_STATUSES.has(v)) return false;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
if (typeof e.code === "string" && /^(ECONNRESET|ECONNREFUSED|ETIMEDOUT|ECONNABORTED|ENOTFOUND|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH)$/.test(e.code)) {
|
|
1458
|
+
return true;
|
|
1459
|
+
}
|
|
1460
|
+
return typeof e.message === "string" ? TRANSIENT_MESSAGE_RE.test(e.message) : false;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Construct an abort-shaped error (name "AbortError", message "aborted") so
|
|
1465
|
+
* callers classify a mid-retry user cancel as cancellation — not as the last
|
|
1466
|
+
* transient provider error. The message "aborted" matches the exact-string
|
|
1467
|
+
* checks call sites already use for the cancelled path.
|
|
1468
|
+
*/
|
|
1469
|
+
export function createAbortError(): Error {
|
|
1470
|
+
const err = new Error("aborted");
|
|
1471
|
+
err.name = "AbortError";
|
|
1472
|
+
return err;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Backoff delay before retry attempt N (0-based): exponential 1s·2^N capped
|
|
1477
|
+
* at 8s, plus 0–30% jitter to de-synchronize parallel image calls.
|
|
1478
|
+
*/
|
|
1479
|
+
export function retryDelayMs(attempt: number): number {
|
|
1480
|
+
const base = Math.min(1000 * 2 ** attempt, 8000);
|
|
1481
|
+
return Math.round(base + Math.random() * 0.3 * base);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Sleep for ms, resolving early with false when the signal aborts. Never
|
|
1486
|
+
* rejects; callers treat a false result as cancellation.
|
|
1487
|
+
*/
|
|
1488
|
+
export function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<boolean> {
|
|
1489
|
+
return new Promise((resolve) => {
|
|
1490
|
+
if (signal?.aborted) {
|
|
1491
|
+
resolve(false);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const onAbort = () => {
|
|
1495
|
+
cleanup();
|
|
1496
|
+
resolve(false);
|
|
1497
|
+
};
|
|
1498
|
+
const timer = setTimeout(() => {
|
|
1499
|
+
cleanup();
|
|
1500
|
+
resolve(true);
|
|
1501
|
+
}, ms);
|
|
1502
|
+
const cleanup = () => {
|
|
1503
|
+
clearTimeout(timer);
|
|
1504
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1505
|
+
};
|
|
1506
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1292
1510
|
// ── Image helpers ──────────────────────────────────────────────────────────
|
|
1293
1511
|
|
|
1294
1512
|
export function toPiAiImage(img: PiAiImage | LegacyImage): PiAiImage {
|
|
@@ -2243,12 +2461,21 @@ const CROP_WORKER_SRC = `
|
|
|
2243
2461
|
const { parentPort, workerData } = require("worker_threads");
|
|
2244
2462
|
const { Image } = require(workerData.imagescriptPath);
|
|
2245
2463
|
parentPort.on("message", async (task) => {
|
|
2246
|
-
const { bytes,
|
|
2464
|
+
const { bytes, mimeType, maxDim } = task;
|
|
2247
2465
|
try {
|
|
2248
2466
|
const img = await Image.decode(new Uint8Array(bytes));
|
|
2249
2467
|
if (img.width > maxDim || img.height > maxDim) { parentPort.postMessage({ ok: false }); return; }
|
|
2250
|
-
|
|
2251
|
-
|
|
2468
|
+
let encoded;
|
|
2469
|
+
if (task.op === "resize") {
|
|
2470
|
+
const scale = Math.min(task.targetDim / img.width, task.targetDim / img.height, 1);
|
|
2471
|
+
const w = Math.max(1, Math.round(img.width * scale));
|
|
2472
|
+
const h = Math.max(1, Math.round(img.height * scale));
|
|
2473
|
+
// Downscaled uploads are re-encoded as JPEG — the size reduction is the point.
|
|
2474
|
+
encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(88);
|
|
2475
|
+
} else {
|
|
2476
|
+
const cropped = img.crop(task.crop.x, task.crop.y, task.crop.width, task.crop.height);
|
|
2477
|
+
encoded = mimeType === "image/png" ? await cropped.encode(1) : await cropped.encodeJPEG(90);
|
|
2478
|
+
}
|
|
2252
2479
|
const u8 = encoded instanceof Uint8Array ? encoded : new Uint8Array(encoded);
|
|
2253
2480
|
const out = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
2254
2481
|
parentPort.postMessage({ ok: true, data: out }, [out]);
|
|
@@ -2350,10 +2577,15 @@ function releaseWorker(worker: NodeWorker): void {
|
|
|
2350
2577
|
});
|
|
2351
2578
|
}
|
|
2352
2579
|
|
|
2353
|
-
/**
|
|
2580
|
+
/** A crop or resize job for the decode worker (1.16.0 added the resize op). */
|
|
2581
|
+
type ImageWorkerTask =
|
|
2582
|
+
| { op?: "crop"; bytes: ArrayBuffer; crop: ResolvedCrop; mimeType?: string; maxDim: number }
|
|
2583
|
+
| { op: "resize"; bytes: ArrayBuffer; targetDim: number; mimeType?: string; maxDim: number };
|
|
2584
|
+
|
|
2585
|
+
/** Run one crop/resize task on a worker with a hard timeout. `reusable` is false on timeout/error. */
|
|
2354
2586
|
function runCropTask(
|
|
2355
2587
|
worker: NodeWorker,
|
|
2356
|
-
task:
|
|
2588
|
+
task: ImageWorkerTask,
|
|
2357
2589
|
timeoutMs: number,
|
|
2358
2590
|
): Promise<{ result: Buffer | null; reusable: boolean }> {
|
|
2359
2591
|
return new Promise((resolve) => {
|
|
@@ -2407,7 +2639,7 @@ async function cropInWorker(
|
|
|
2407
2639
|
|
|
2408
2640
|
const { result, reusable } = await runCropTask(
|
|
2409
2641
|
worker,
|
|
2410
|
-
{ bytes: ab, crop, mimeType, maxDim: MAX_IMAGE_DIMENSION },
|
|
2642
|
+
{ op: "crop", bytes: ab, crop, mimeType, maxDim: MAX_IMAGE_DIMENSION },
|
|
2411
2643
|
timeoutMs,
|
|
2412
2644
|
);
|
|
2413
2645
|
if (reusable) releaseWorker(worker);
|
|
@@ -2475,6 +2707,124 @@ export function bufferToPiAiImage(buf: Buffer, originalMimeType?: string): PiAiI
|
|
|
2475
2707
|
return { type: "image", data: buf.toString("base64"), mimeType };
|
|
2476
2708
|
}
|
|
2477
2709
|
|
|
2710
|
+
// ── Upload downscale (1.16.0) ───────────────────────────────────────────────
|
|
2711
|
+
|
|
2712
|
+
/**
|
|
2713
|
+
* Pure decision helper: whether an image exceeds the configured upload
|
|
2714
|
+
* thresholds, and the long-edge bound to downscale to when it does. Returns
|
|
2715
|
+
* null when the image is small enough to send as-is.
|
|
2716
|
+
*/
|
|
2717
|
+
export function downscaleTargetDim(
|
|
2718
|
+
dims: { width: number; height: number } | undefined,
|
|
2719
|
+
byteLength: number,
|
|
2720
|
+
config: Pick<VisionConfig, "maxUploadDim" | "maxUploadBytes">,
|
|
2721
|
+
): number | null {
|
|
2722
|
+
// 0 = downscaling disabled entirely (including the byte trigger).
|
|
2723
|
+
if (config.maxUploadDim === 0) return null;
|
|
2724
|
+
return overUploadDim(dims, config) || byteLength > config.maxUploadBytes ? config.maxUploadDim : null;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
/**
|
|
2728
|
+
* True when either edge exceeds the configured upload dimension. Shared by
|
|
2729
|
+
* downscaleTargetDim and downscaleForUpload so the threshold decision cannot
|
|
2730
|
+
* drift between them (CodeRabbit PR #27). 0 = disabled → always false.
|
|
2731
|
+
*/
|
|
2732
|
+
export function overUploadDim(
|
|
2733
|
+
dims: { width: number; height: number } | undefined,
|
|
2734
|
+
config: Pick<VisionConfig, "maxUploadDim">,
|
|
2735
|
+
): boolean {
|
|
2736
|
+
if (config.maxUploadDim === 0) return false;
|
|
2737
|
+
return Boolean(dims && (dims.width > config.maxUploadDim || dims.height > config.maxUploadDim));
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
/** In-thread decode → resize → JPEG encode, mirroring cropInThread. */
|
|
2741
|
+
async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
|
|
2742
|
+
const img = await decodeWithTimeout(imageBytes);
|
|
2743
|
+
if (img.width > MAX_IMAGE_DIMENSION || img.height > MAX_IMAGE_DIMENSION) {
|
|
2744
|
+
return null;
|
|
2745
|
+
}
|
|
2746
|
+
const scale = Math.min(targetDim / img.width, targetDim / img.height, 1);
|
|
2747
|
+
const w = Math.max(1, Math.round(img.width * scale));
|
|
2748
|
+
const h = Math.max(1, Math.round(img.height * scale));
|
|
2749
|
+
const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(88);
|
|
2750
|
+
return Buffer.from(encoded);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
/** Worker-path resize — same pooling/timeout guarantees as cropInWorker. */
|
|
2754
|
+
async function resizeInWorker(
|
|
2755
|
+
imageBytes: Buffer,
|
|
2756
|
+
targetDim: number,
|
|
2757
|
+
timeoutMs: number,
|
|
2758
|
+
): Promise<Buffer | null | typeof WORKER_UNAVAILABLE> {
|
|
2759
|
+
if (!(await ensureWorkerInfra())) return WORKER_UNAVAILABLE;
|
|
2760
|
+
|
|
2761
|
+
let worker: NodeWorker;
|
|
2762
|
+
try {
|
|
2763
|
+
worker = acquireWorker();
|
|
2764
|
+
} catch {
|
|
2765
|
+
return WORKER_UNAVAILABLE;
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
const ab = imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength);
|
|
2769
|
+
|
|
2770
|
+
const { result, reusable } = await runCropTask(
|
|
2771
|
+
worker,
|
|
2772
|
+
{ op: "resize", bytes: ab, targetDim, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
|
|
2773
|
+
timeoutMs,
|
|
2774
|
+
);
|
|
2775
|
+
if (reusable) releaseWorker(worker);
|
|
2776
|
+
else void worker.terminate();
|
|
2777
|
+
return result;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
/**
|
|
2781
|
+
* Downscale image bytes so the long edge fits targetDim, re-encoding as
|
|
2782
|
+
* JPEG q88. Returns null on decode/encode failure — callers then send the
|
|
2783
|
+
* original bytes rather than failing the whole analysis.
|
|
2784
|
+
*/
|
|
2785
|
+
export async function downscaleImage(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
|
|
2786
|
+
try {
|
|
2787
|
+
const dims = extractDimensions(imageBytes);
|
|
2788
|
+
if (dims && (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION)) {
|
|
2789
|
+
return null;
|
|
2790
|
+
}
|
|
2791
|
+
if (decodeWorkerEnabled()) {
|
|
2792
|
+
const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs());
|
|
2793
|
+
if (viaWorker !== WORKER_UNAVAILABLE) return viaWorker;
|
|
2794
|
+
// else: worker infra unavailable — fall through to the in-thread path
|
|
2795
|
+
}
|
|
2796
|
+
return await resizeInThread(imageBytes, targetDim);
|
|
2797
|
+
} catch {
|
|
2798
|
+
return null;
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
/**
|
|
2803
|
+
* Downscale an image for upload when it exceeds the configured thresholds
|
|
2804
|
+
* (1.16.0). Returns the original image untouched when it is small enough or
|
|
2805
|
+
* when downscaling fails for any reason — upload downscale is best-effort
|
|
2806
|
+
* cost/limit protection, never a hard failure.
|
|
2807
|
+
*
|
|
2808
|
+
* Dimension-triggered resizes are accepted even when the JPEG re-encode grows
|
|
2809
|
+
* the payload slightly (flat-color PNGs compress better than JPEG) — the pixel
|
|
2810
|
+
* reduction is what matters for provider dimension limits. Byte-budget-triggered
|
|
2811
|
+
* resizes only pay off when the result is genuinely smaller.
|
|
2812
|
+
*/
|
|
2813
|
+
export async function downscaleForUpload(
|
|
2814
|
+
img: PiAiImage,
|
|
2815
|
+
config: Pick<VisionConfig, "maxUploadDim" | "maxUploadBytes">,
|
|
2816
|
+
): Promise<PiAiImage> {
|
|
2817
|
+
if (config.maxUploadDim === 0) return img; // downscaling disabled
|
|
2818
|
+
const buf = piAiImageToBuffer(img);
|
|
2819
|
+
const dims = extractDimensions(buf);
|
|
2820
|
+
const overDim = overUploadDim(dims, config);
|
|
2821
|
+
if (downscaleTargetDim(dims, buf.byteLength, config) === null) return img;
|
|
2822
|
+
const resized = await downscaleImage(buf, config.maxUploadDim);
|
|
2823
|
+
if (!resized) return img;
|
|
2824
|
+
if (!overDim && resized.byteLength >= buf.byteLength) return img;
|
|
2825
|
+
return bufferToPiAiImage(resized, "image/jpeg");
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2478
2828
|
// ── Perceptual hashing (imghash) ────────────────────────────────────────────
|
|
2479
2829
|
|
|
2480
2830
|
let _imghash: typeof import("imghash") | null = null;
|