pi-multimodal-proxy 1.18.1 → 1.19.1
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 +30 -0
- package/README.md +31 -0
- package/extensions/__tests__/internal.test.ts +199 -3
- package/extensions/__tests__/recall-autocomplete.test.ts +10 -0
- package/extensions/internal.ts +215 -19
- package/extensions/vision-proxy.ts +635 -52
- package/package.json +1 -1
package/extensions/internal.ts
CHANGED
|
@@ -94,6 +94,15 @@ export interface VisionConfig {
|
|
|
94
94
|
maxUploadBytes: number;
|
|
95
95
|
fallbackProvider?: string;
|
|
96
96
|
fallbackModelId?: string;
|
|
97
|
+
// 1.19.0 — virtual model (pi ≥ 0.99): registers `multimodal-proxy/auto`, a
|
|
98
|
+
// selectable model that routes media-bearing turns to the configured vision
|
|
99
|
+
// model and text turns to a base model. "on" registers it, "off" never
|
|
100
|
+
// does. The optional base pin names the physical text model; unset = stay on
|
|
101
|
+
// whatever physical model handled the session before, falling back to the
|
|
102
|
+
// vision model for the first turn.
|
|
103
|
+
virtualModel: ToolSetting;
|
|
104
|
+
virtualBaseProvider?: string;
|
|
105
|
+
virtualBaseModelId?: string;
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
export interface ImageMeta {
|
|
@@ -727,6 +736,9 @@ export const DEFAULT_CONFIG: VisionConfig = {
|
|
|
727
736
|
retryMax: 2,
|
|
728
737
|
maxUploadDim: 2048,
|
|
729
738
|
maxUploadBytes: 5 * 1024 * 1024,
|
|
739
|
+
// 1.19.0 — virtual model registered on hosts with pi.registerVirtualModel()
|
|
740
|
+
// (pi ≥ 0.99); feature-detected and skipped on older hosts.
|
|
741
|
+
virtualModel: "on",
|
|
730
742
|
};
|
|
731
743
|
|
|
732
744
|
// ── Persistent file storage ────────────────────────────────────────────────
|
|
@@ -759,6 +771,9 @@ const PERSISTED_CONFIG_KEYS = new Set([
|
|
|
759
771
|
"maxUploadBytes",
|
|
760
772
|
"fallbackProvider",
|
|
761
773
|
"fallbackModelId",
|
|
774
|
+
"virtualModel",
|
|
775
|
+
"virtualBaseProvider",
|
|
776
|
+
"virtualBaseModelId",
|
|
762
777
|
]);
|
|
763
778
|
|
|
764
779
|
/** Read config from the persistent file. Returns empty object on any failure. */
|
|
@@ -871,6 +886,7 @@ export function parseFallbackModelEnv(
|
|
|
871
886
|
return parsed ? { provider: parsed.provider, modelId: parsed.modelId } : undefined;
|
|
872
887
|
}
|
|
873
888
|
|
|
889
|
+
/** Environment overrides (PI_VISION_PROXY_*) applied last; only recognized values land. */
|
|
874
890
|
export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<VisionConfig> {
|
|
875
891
|
const overrides: Partial<VisionConfig> = {};
|
|
876
892
|
const modeEnv = env.PI_VISION_PROXY_MODE;
|
|
@@ -966,10 +982,24 @@ export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<
|
|
|
966
982
|
overrides.fallbackModelId = fallbackModelEnv.modelId;
|
|
967
983
|
}
|
|
968
984
|
}
|
|
985
|
+
// 1.19.0 virtual-model overrides
|
|
986
|
+
const virtualEnv = env.PI_VISION_PROXY_VIRTUAL;
|
|
987
|
+
if (virtualEnv === "on" || virtualEnv === "off") overrides.virtualModel = virtualEnv;
|
|
988
|
+
const virtualBaseEnv = parseFallbackModelEnv(env.PI_VISION_PROXY_VIRTUAL_BASE);
|
|
989
|
+
if (virtualBaseEnv) {
|
|
990
|
+
if (virtualBaseEnv.clear) {
|
|
991
|
+
overrides.virtualBaseProvider = undefined;
|
|
992
|
+
overrides.virtualBaseModelId = undefined;
|
|
993
|
+
} else {
|
|
994
|
+
overrides.virtualBaseProvider = virtualBaseEnv.provider;
|
|
995
|
+
overrides.virtualBaseModelId = virtualBaseEnv.modelId;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
969
998
|
return overrides;
|
|
970
999
|
}
|
|
971
1000
|
|
|
972
|
-
|
|
1001
|
+
/** Which env vars actually override a setting — used to lock the matching commands. */
|
|
1002
|
+
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; virtual: boolean; virtualBase: boolean } {
|
|
973
1003
|
return {
|
|
974
1004
|
mode: Boolean(env.PI_VISION_PROXY_MODE),
|
|
975
1005
|
model: Boolean(env.PI_VISION_PROXY_MODEL),
|
|
@@ -998,6 +1028,9 @@ export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean;
|
|
|
998
1028
|
parseUploadDimEnv(env.PI_VISION_PROXY_MAX_UPLOAD_DIM) !== undefined ||
|
|
999
1029
|
parseUploadMbEnv(env.PI_VISION_PROXY_MAX_UPLOAD_MB) !== undefined,
|
|
1000
1030
|
fallbackModel: parseFallbackModelEnv(env.PI_VISION_PROXY_FALLBACK_MODEL) !== undefined,
|
|
1031
|
+
// 1.19.0 — only recognized values count as overrides.
|
|
1032
|
+
virtual: env.PI_VISION_PROXY_VIRTUAL === "on" || env.PI_VISION_PROXY_VIRTUAL === "off",
|
|
1033
|
+
virtualBase: parseFallbackModelEnv(env.PI_VISION_PROXY_VIRTUAL_BASE) !== undefined,
|
|
1001
1034
|
};
|
|
1002
1035
|
}
|
|
1003
1036
|
|
|
@@ -1139,6 +1172,7 @@ export function sanitizeProviderHeaders(
|
|
|
1139
1172
|
return out;
|
|
1140
1173
|
}
|
|
1141
1174
|
|
|
1175
|
+
/** Validate and normalize a merged config: canonicalize providers, clamp numbers, drop malformed pairs. */
|
|
1142
1176
|
export function sanitize(config: VisionConfig): VisionConfig {
|
|
1143
1177
|
const safe: VisionConfig = { ...config };
|
|
1144
1178
|
if (typeof safe.provider === "string") safe.provider = canonicalProvider(safe.provider);
|
|
@@ -1244,6 +1278,17 @@ export function sanitize(config: VisionConfig): VisionConfig {
|
|
|
1244
1278
|
delete safe.fallbackProvider;
|
|
1245
1279
|
delete safe.fallbackModelId;
|
|
1246
1280
|
}
|
|
1281
|
+
// 1.19.0 virtual-model fields
|
|
1282
|
+
if (safe.virtualModel !== "on" && safe.virtualModel !== "off") safe.virtualModel = DEFAULT_CONFIG.virtualModel;
|
|
1283
|
+
if (typeof safe.virtualBaseProvider !== "string") delete safe.virtualBaseProvider;
|
|
1284
|
+
else safe.virtualBaseProvider = canonicalProvider(safe.virtualBaseProvider);
|
|
1285
|
+
if (typeof safe.virtualBaseModelId !== "string") delete safe.virtualBaseModelId;
|
|
1286
|
+
if (safe.virtualBaseProvider !== undefined && !PROVIDER_PATTERN.test(safe.virtualBaseProvider)) delete safe.virtualBaseProvider;
|
|
1287
|
+
if (safe.virtualBaseModelId !== undefined && !MODEL_ID_PATTERN.test(safe.virtualBaseModelId)) delete safe.virtualBaseModelId;
|
|
1288
|
+
if (!safe.virtualBaseProvider || !safe.virtualBaseModelId) {
|
|
1289
|
+
delete safe.virtualBaseProvider;
|
|
1290
|
+
delete safe.virtualBaseModelId;
|
|
1291
|
+
}
|
|
1247
1292
|
return safe;
|
|
1248
1293
|
}
|
|
1249
1294
|
|
|
@@ -1251,6 +1296,11 @@ export function persistedBase(entries: readonly SessionEntry[]): VisionConfig {
|
|
|
1251
1296
|
return sanitize({ ...DEFAULT_CONFIG, ...readPersistedConfig(entries) });
|
|
1252
1297
|
}
|
|
1253
1298
|
|
|
1299
|
+
/**
|
|
1300
|
+
* Resolve the effective config: defaults ← persisted file ← session-entry
|
|
1301
|
+
* config ← environment overrides, then sanitize (invalid values fall back to
|
|
1302
|
+
* defaults; half-set model pairs are dropped).
|
|
1303
|
+
*/
|
|
1254
1304
|
export function resolveConfig(
|
|
1255
1305
|
entries: readonly SessionEntry[],
|
|
1256
1306
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -2323,6 +2373,68 @@ export function selectVisionModels<T extends VisionModelLike>(
|
|
|
2323
2373
|
return all.filter((m) => m.input.includes("image"));
|
|
2324
2374
|
}
|
|
2325
2375
|
|
|
2376
|
+
/** Lowercased bigram set of a string, for Dice-coefficient similarity. */
|
|
2377
|
+
function bigrams(s: string): Set<string> {
|
|
2378
|
+
const t = s.toLowerCase();
|
|
2379
|
+
const out = new Set<string>();
|
|
2380
|
+
for (let i = 0; i + 1 < t.length; i++) out.add(t.slice(i, i + 2));
|
|
2381
|
+
return out;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/**
|
|
2385
|
+
* Close-match suggestions for a mistyped identifier, in the spirit of Pi
|
|
2386
|
+
* 1.0's codemode recovery errors ("tools.Bash suggests tools.bash").
|
|
2387
|
+
* Ranks by Dice bigram similarity with a strong exact/substring bonus;
|
|
2388
|
+
* returns at most `limit` candidates above the relevance floor, best first,
|
|
2389
|
+
* ties broken alphabetically so the output is deterministic.
|
|
2390
|
+
*/
|
|
2391
|
+
export function closeMatches(query: string, candidates: readonly string[], limit = 3): string[] {
|
|
2392
|
+
if (!query || candidates.length === 0) return [];
|
|
2393
|
+
const q = query.toLowerCase();
|
|
2394
|
+
const scored = candidates.map((candidate) => {
|
|
2395
|
+
const c = candidate.toLowerCase();
|
|
2396
|
+
let score: number;
|
|
2397
|
+
if (c === q) score = 2;
|
|
2398
|
+
else if (c.includes(q) || q.includes(c)) score = 1.5;
|
|
2399
|
+
else {
|
|
2400
|
+
const bq = bigrams(q);
|
|
2401
|
+
const bc = bigrams(c);
|
|
2402
|
+
let shared = 0;
|
|
2403
|
+
for (const g of bq) if (bc.has(g)) shared++;
|
|
2404
|
+
score = bq.size === 0 || bc.size === 0 ? 0 : (2 * shared) / (bq.size + bc.size);
|
|
2405
|
+
}
|
|
2406
|
+
return { candidate, score };
|
|
2407
|
+
});
|
|
2408
|
+
return scored
|
|
2409
|
+
.filter((s) => s.score >= 0.3)
|
|
2410
|
+
.sort((a, b) => b.score - a.score || (a.candidate < b.candidate ? -1 : a.candidate > b.candidate ? 1 : 0))
|
|
2411
|
+
.slice(0, limit)
|
|
2412
|
+
.map((s) => s.candidate);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/** Minimal shape for dynamic (registry-provided) vision candidates. */
|
|
2416
|
+
export interface DynamicCandidateLike {
|
|
2417
|
+
provider: string;
|
|
2418
|
+
modelId: string;
|
|
2419
|
+
input?: readonly string[];
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/** Cheap-tier heuristic for implicit default resolution (Pi 1.0 last resort). */
|
|
2423
|
+
const CHEAP_MODEL_RE = /flash|mini|fast|haiku|express|lite/i;
|
|
2424
|
+
|
|
2425
|
+
/**
|
|
2426
|
+
* First keyed registry model with image input — the last-resort implicit
|
|
2427
|
+
* vision default when the curated fallback chain is entirely unkeyed
|
|
2428
|
+
* (Pi ≥ 1.0 `getAvailableOfType("chat")`). Cheap-tier ids are preferred so
|
|
2429
|
+
* an implicit default never lands on a frontier-priced model; registry
|
|
2430
|
+
* order breaks remaining ties.
|
|
2431
|
+
*/
|
|
2432
|
+
export function pickDynamicVisionCandidate<T extends DynamicCandidateLike>(models: readonly T[]): T | undefined {
|
|
2433
|
+
const vision = models.filter((m) => m.input?.includes("image"));
|
|
2434
|
+
if (vision.length === 0) return undefined;
|
|
2435
|
+
return vision.find((m) => CHEAP_MODEL_RE.test(m.modelId)) ?? vision[0];
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2326
2438
|
export function shouldStripImages(config: VisionConfig, modelInput: readonly string[] | undefined): boolean {
|
|
2327
2439
|
if (config.mode === "off") return false;
|
|
2328
2440
|
if (config.mode === "always") return true;
|
|
@@ -2474,12 +2586,20 @@ export function resolveCropEntry(crop: CropEntry, imgWidth: number, imgHeight: n
|
|
|
2474
2586
|
}
|
|
2475
2587
|
if ("normalized" in crop) {
|
|
2476
2588
|
const result = normalizedToPixels(crop.normalized, imgWidth, imgHeight);
|
|
2477
|
-
if (!result)
|
|
2589
|
+
if (!result)
|
|
2590
|
+
throw new Error(
|
|
2591
|
+
`Normalized crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
|
|
2592
|
+
`Use fractions of the image: x ≥ 0, y ≥ 0, x + width ≤ 1, y + height ≤ 1.`,
|
|
2593
|
+
);
|
|
2478
2594
|
return result;
|
|
2479
2595
|
}
|
|
2480
2596
|
if ("pixels" in crop) {
|
|
2481
2597
|
const result = clampPixels(crop.pixels, imgWidth, imgHeight);
|
|
2482
|
-
if (!result)
|
|
2598
|
+
if (!result)
|
|
2599
|
+
throw new Error(
|
|
2600
|
+
`Pixel crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
|
|
2601
|
+
`Keep the rectangle inside the image: x ≥ 0, y ≥ 0, x + width ≤ ${imgWidth}, y + height ≤ ${imgHeight}.`,
|
|
2602
|
+
);
|
|
2483
2603
|
return result;
|
|
2484
2604
|
}
|
|
2485
2605
|
throw new Error("Invalid CropEntry: must have exactly one of region, normalized, or pixels");
|
|
@@ -2606,7 +2726,7 @@ parentPort.on("message", async (task) => {
|
|
|
2606
2726
|
const w = Math.max(1, Math.round(img.width * scale));
|
|
2607
2727
|
const h = Math.max(1, Math.round(img.height * scale));
|
|
2608
2728
|
// Downscaled uploads are re-encoded as JPEG — the size reduction is the point.
|
|
2609
|
-
encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(88);
|
|
2729
|
+
encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(task.quality ?? 88);
|
|
2610
2730
|
} else {
|
|
2611
2731
|
const cropped = img.crop(task.crop.x, task.crop.y, task.crop.width, task.crop.height);
|
|
2612
2732
|
encoded = mimeType === "image/png" ? await cropped.encode(1) : await cropped.encodeJPEG(90);
|
|
@@ -2873,7 +2993,7 @@ export function overUploadDim(
|
|
|
2873
2993
|
}
|
|
2874
2994
|
|
|
2875
2995
|
/** In-thread decode → resize → JPEG encode, mirroring cropInThread. */
|
|
2876
|
-
async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
|
|
2996
|
+
async function resizeInThread(imageBytes: Buffer, targetDim: number, quality = 88): Promise<Buffer | null> {
|
|
2877
2997
|
const img = await decodeWithTimeout(imageBytes);
|
|
2878
2998
|
if (img.width > MAX_IMAGE_DIMENSION || img.height > MAX_IMAGE_DIMENSION) {
|
|
2879
2999
|
return null;
|
|
@@ -2881,7 +3001,7 @@ async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Bu
|
|
|
2881
3001
|
const scale = Math.min(targetDim / img.width, targetDim / img.height, 1);
|
|
2882
3002
|
const w = Math.max(1, Math.round(img.width * scale));
|
|
2883
3003
|
const h = Math.max(1, Math.round(img.height * scale));
|
|
2884
|
-
const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(
|
|
3004
|
+
const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(quality);
|
|
2885
3005
|
return Buffer.from(encoded);
|
|
2886
3006
|
}
|
|
2887
3007
|
|
|
@@ -2890,6 +3010,7 @@ async function resizeInWorker(
|
|
|
2890
3010
|
imageBytes: Buffer,
|
|
2891
3011
|
targetDim: number,
|
|
2892
3012
|
timeoutMs: number,
|
|
3013
|
+
quality?: number,
|
|
2893
3014
|
): Promise<Buffer | null | typeof WORKER_UNAVAILABLE> {
|
|
2894
3015
|
if (!(await ensureWorkerInfra())) return WORKER_UNAVAILABLE;
|
|
2895
3016
|
|
|
@@ -2904,7 +3025,7 @@ async function resizeInWorker(
|
|
|
2904
3025
|
|
|
2905
3026
|
const { result, reusable } = await runCropTask(
|
|
2906
3027
|
worker,
|
|
2907
|
-
{ op: "resize", bytes: ab, targetDim, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
|
|
3028
|
+
{ op: "resize", bytes: ab, targetDim, quality, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
|
|
2908
3029
|
timeoutMs,
|
|
2909
3030
|
);
|
|
2910
3031
|
if (reusable) releaseWorker(worker);
|
|
@@ -2913,32 +3034,97 @@ async function resizeInWorker(
|
|
|
2913
3034
|
}
|
|
2914
3035
|
|
|
2915
3036
|
/**
|
|
2916
|
-
* Downscale image bytes so the long edge fits targetDim, re-encoding as
|
|
2917
|
-
*
|
|
2918
|
-
* original bytes rather than failing the whole analysis.
|
|
3037
|
+
* Downscale image bytes so the long edge fits targetDim, re-encoding as JPEG
|
|
3038
|
+
* (quality 88 unless overridden). Returns null on decode/encode failure —
|
|
3039
|
+
* callers then send the original bytes rather than failing the whole analysis.
|
|
2919
3040
|
*/
|
|
2920
|
-
export async function downscaleImage(
|
|
3041
|
+
export async function downscaleImage(
|
|
3042
|
+
imageBytes: Buffer,
|
|
3043
|
+
targetDim: number,
|
|
3044
|
+
quality = 88,
|
|
3045
|
+
): Promise<Buffer | null> {
|
|
2921
3046
|
try {
|
|
2922
3047
|
const dims = extractDimensions(imageBytes);
|
|
2923
3048
|
if (dims && (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION)) {
|
|
2924
3049
|
return null;
|
|
2925
3050
|
}
|
|
2926
3051
|
if (decodeWorkerEnabled()) {
|
|
2927
|
-
const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs());
|
|
3052
|
+
const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs(), quality);
|
|
2928
3053
|
if (viaWorker !== WORKER_UNAVAILABLE) return viaWorker;
|
|
2929
3054
|
// else: worker infra unavailable — fall through to the in-thread path
|
|
2930
3055
|
}
|
|
2931
|
-
return await resizeInThread(imageBytes, targetDim);
|
|
3056
|
+
return await resizeInThread(imageBytes, targetDim, quality);
|
|
2932
3057
|
} catch {
|
|
2933
3058
|
return null;
|
|
2934
3059
|
}
|
|
2935
3060
|
}
|
|
2936
3061
|
|
|
3062
|
+
/**
|
|
3063
|
+
* Per-model image upload limits declared on a catalog entry (pi ≥ 0.87):
|
|
3064
|
+
* `inputLimits.images.resize` = { maxWidth, maxHeight, maxBytes, jpegQuality }.
|
|
3065
|
+
* `maxBytes` limits the base64 payload; here it is converted to a raw-byte
|
|
3066
|
+
* budget (~3/4) so it can share the raw-byte comparison of the user config.
|
|
3067
|
+
* Returns null when the model declares nothing (or an older pi host).
|
|
3068
|
+
*/
|
|
3069
|
+
export interface ModelImageResizeLimits {
|
|
3070
|
+
/** Long-edge target that satisfies the model's max-width/max-height box. Absent when the model declares no dimension limits (byte-budget-only declarations keep the user's dim target). */
|
|
3071
|
+
targetDim?: number;
|
|
3072
|
+
/** JPEG re-encode quality (default 88). */
|
|
3073
|
+
quality: number;
|
|
3074
|
+
/** Raw-byte budget derived from the base64 maxBytes, when declared. */
|
|
3075
|
+
maxBytes?: number;
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
export function modelImageResizeLimits(model: unknown): ModelImageResizeLimits | null {
|
|
3079
|
+
const resize = (
|
|
3080
|
+
model as
|
|
3081
|
+
| {
|
|
3082
|
+
inputLimits?: {
|
|
3083
|
+
images?: {
|
|
3084
|
+
resize?: {
|
|
3085
|
+
maxWidth?: unknown;
|
|
3086
|
+
maxHeight?: unknown;
|
|
3087
|
+
maxBytes?: unknown;
|
|
3088
|
+
jpegQuality?: unknown;
|
|
3089
|
+
};
|
|
3090
|
+
};
|
|
3091
|
+
};
|
|
3092
|
+
}
|
|
3093
|
+
| null
|
|
3094
|
+
| undefined
|
|
3095
|
+
)?.inputLimits?.images?.resize;
|
|
3096
|
+
if (!resize) return null;
|
|
3097
|
+
const maxW = typeof resize.maxWidth === "number" && resize.maxWidth > 0 ? resize.maxWidth : undefined;
|
|
3098
|
+
const maxH = typeof resize.maxHeight === "number" && resize.maxHeight > 0 ? resize.maxHeight : undefined;
|
|
3099
|
+
const dims = [maxW, maxH].filter((d): d is number => d !== undefined);
|
|
3100
|
+
const quality =
|
|
3101
|
+
typeof resize.jpegQuality === "number" && resize.jpegQuality >= 1 && resize.jpegQuality <= 100
|
|
3102
|
+
? Math.round(resize.jpegQuality)
|
|
3103
|
+
: 88;
|
|
3104
|
+
const maxBytes =
|
|
3105
|
+
typeof resize.maxBytes === "number" && resize.maxBytes > 0
|
|
3106
|
+
? Math.max(1, Math.floor(resize.maxBytes * 0.75))
|
|
3107
|
+
: undefined;
|
|
3108
|
+
// A declaration must carry at least one meaningful limit — dimensions or a
|
|
3109
|
+
// byte budget. Quality alone changes nothing until a resize triggers, so a
|
|
3110
|
+
// quality-only declaration is treated as absent. A maxBytes-only
|
|
3111
|
+
// declaration is honored (PR #34 review): targetDim stays unset and only
|
|
3112
|
+
// the byte budget and quality tighten the user's thresholds.
|
|
3113
|
+
if (dims.length === 0 && maxBytes === undefined) return null;
|
|
3114
|
+
return {
|
|
3115
|
+
targetDim: dims.length > 0 ? Math.min(...dims) : undefined,
|
|
3116
|
+
quality,
|
|
3117
|
+
maxBytes,
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
|
|
2937
3121
|
/**
|
|
2938
3122
|
* Downscale an image for upload when it exceeds the configured thresholds
|
|
2939
|
-
* (1.16.0).
|
|
2940
|
-
*
|
|
2941
|
-
*
|
|
3123
|
+
* (1.16.0). Model-declared limits (pi ≥ 0.87 `inputLimits.images.resize`,
|
|
3124
|
+
* 1.19.0) tighten the user's thresholds; they never re-enable a downscale the
|
|
3125
|
+
* user disabled with `maxUploadDim: 0`. Returns the original image untouched
|
|
3126
|
+
* when it is small enough or when downscaling fails for any reason — upload
|
|
3127
|
+
* downscale is best-effort cost/limit protection, never a hard failure.
|
|
2942
3128
|
*
|
|
2943
3129
|
* Dimension-triggered resizes are accepted even when the JPEG re-encode grows
|
|
2944
3130
|
* the payload slightly (flat-color PNGs compress better than JPEG) — the pixel
|
|
@@ -2948,13 +3134,23 @@ export async function downscaleImage(imageBytes: Buffer, targetDim: number): Pro
|
|
|
2948
3134
|
export async function downscaleForUpload(
|
|
2949
3135
|
img: PiAiImage,
|
|
2950
3136
|
config: Pick<VisionConfig, "maxUploadDim" | "maxUploadBytes">,
|
|
3137
|
+
modelLimits?: ModelImageResizeLimits | null,
|
|
2951
3138
|
): Promise<PiAiImage> {
|
|
2952
3139
|
if (config.maxUploadDim === 0) return img; // downscaling disabled
|
|
3140
|
+
const targetDim = modelLimits?.targetDim
|
|
3141
|
+
? Math.min(config.maxUploadDim, modelLimits.targetDim)
|
|
3142
|
+
: config.maxUploadDim;
|
|
3143
|
+
const maxUploadBytes =
|
|
3144
|
+
modelLimits?.maxBytes !== undefined
|
|
3145
|
+
? Math.min(config.maxUploadBytes, modelLimits.maxBytes)
|
|
3146
|
+
: config.maxUploadBytes;
|
|
3147
|
+
const quality = modelLimits?.quality ?? 88;
|
|
3148
|
+
const effective = { maxUploadDim: targetDim, maxUploadBytes };
|
|
2953
3149
|
const buf = piAiImageToBuffer(img);
|
|
2954
3150
|
const dims = extractDimensions(buf);
|
|
2955
|
-
const overDim = overUploadDim(dims,
|
|
2956
|
-
if (downscaleTargetDim(dims, buf.byteLength,
|
|
2957
|
-
const resized = await downscaleImage(buf,
|
|
3151
|
+
const overDim = overUploadDim(dims, effective);
|
|
3152
|
+
if (downscaleTargetDim(dims, buf.byteLength, effective) === null) return img;
|
|
3153
|
+
const resized = await downscaleImage(buf, targetDim, quality);
|
|
2958
3154
|
if (!resized) return img;
|
|
2959
3155
|
if (!overDim && resized.byteLength >= buf.byteLength) return img;
|
|
2960
3156
|
return bufferToPiAiImage(resized, "image/jpeg");
|