pi-multimodal-proxy 1.18.0 → 1.19.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 +30 -0
- package/README.md +31 -0
- package/extensions/__tests__/internal.test.ts +122 -3
- package/extensions/internal.ts +143 -17
- package/extensions/vision-proxy.ts +533 -48
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [1.19.0] - 2026-10-01
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Virtual model — `Multimodal Auto`** (`multimodal-proxy/auto`, Pi ≥ 0.99, feature-detected). The extension registers a virtual model via `pi.registerVirtualModel()`; selecting it in `/model` routes each request: turns whose pending user input carries image blocks go natively to the configured vision model (real pixels, no description fence), everything else goes to the base model — the pinned `virtual-base` model, else the physical model that answered last (`request.previous`), else the vision model. Sticky routing (`retry` → `failed`, `continuation`/`direct` → `previous`) keeps prompt caches and thinking signatures valid. `route()` resolves config per request (no registration churn on model changes) and throws an actionable error pointing at `/multimodal-proxy pick` when no vision model resolves. New commands `/multimodal-proxy virtual on|off` (default on; `unregisterVirtualModel` when off), `/multimodal-proxy virtual-base <provider/model-id>|off`, env overrides `PI_VISION_PROXY_VIRTUAL` / `PI_VISION_PROXY_VIRTUAL_BASE`, status-line `[+virtual]` marker, interactive-menu entry, and summary/help lines. With the virtual model selected, image stripping never applies (it advertises image input); video/audio detection, `analyze_image`, and consent gates are unchanged.
|
|
12
|
+
- **`analyze_image` adopts the Pi 0.99 extension tool API** (older hosts ignore the new fields): results carry `isError: true` for failures instead of "Error: …" text masquerading as content (all ~18 failure paths now route through an `AnalyzeImageOutcome`), successful calls return an `outputSchema`-shaped `structuredContent` — analysis text, `cached`, fallback-aware `provider`/`model`, `latencyMs`, `groundingFormat`, and per-image `id`/`filename`/dimensions/absolute-pixel `crop` — so codemode scripts and other `ctx.executeTool()` callers receive machine-readable results, and MCP-style `annotations` (`readOnlyHint: true`, `openWorldHint: true`) plus a `multimodal-proxy` namespace group the tool in codemode listings.
|
|
13
|
+
- **Model-aware upload downscale** (Pi ≥ 0.87). `modelImageResizeLimits()` reads the vision model catalog entry's `inputLimits.images.resize` (`maxWidth`/`maxHeight`/`maxBytes`/`jpegQuality`, all optional) and `downscaleForUpload()` tightens the user's `maxUploadDim`/`maxUploadBytes`/quality with it — the derived long-edge target is `min(maxWidth, maxHeight)`, the base64 `maxBytes` budget is converted to raw bytes (×0.75) before mixing with the user's raw budget, and a user-disabled downscale (`maxUploadDim: 0`) always wins. `downscaleImage` gained a `quality` parameter threaded through both the worker task and the in-thread path. All four upload paths (auto-proxy, `analyze_image`, joint descriptions, `/describe`) pass the resolved vision model's limits.
|
|
14
|
+
- **Compaction-proof, cache-stable recall hint** (Pi ≥ 0.87). A `context_with_system` handler — probed on first invocation so the affordance never appears twice — takes over the `analyze_image` recall affordance once media has been described: a stable system-layer block replaces the per-user-message restatement. It survives compaction (which summarized the old hint away) and reaches vision-capable session models, where the strip path never fired. Older hosts keep the exact previous behavior.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `envFlags()` reports two new presence flags (`virtual`, `virtualBase`); the config summary, no-UI command list, and interactive menu show the virtual-model state (including a "(needs pi ≥ 0.99)" qualifier when registered on an older host).
|
|
19
|
+
- Six new tests: env override + flag presence for the virtual vars, `modelImageResizeLimits` derivation (null cases, box target, quality default, raw-byte conversion), model-limits tightening vs. user-disabled downscale vs. in-budget images, and sanitize validation of the new fields (494 total, all green).
|
|
20
|
+
|
|
21
|
+
### Fixed (PR #34 review)
|
|
22
|
+
|
|
23
|
+
- **`modelImageResizeLimits` no longer drops maxBytes-only declarations** — a catalog entry with `inputLimits.images.resize.maxBytes` but no `maxWidth`/`maxHeight` is now honored: `targetDim` stays unset (the user's dim target applies) while the byte budget (converted to raw bytes, floored at 1) and JPEG quality still tighten the upload path. Quality-only declarations remain a no-op.
|
|
24
|
+
- **Virtual-model routing filters for vision-capable entries** — `resolveVisionTarget` only accepts models whose `input` includes `image`, so a text-only pin (via `/multimodal-proxy model` or `PI_VISION_PROXY_MODEL`) can no longer route a media turn to a model that would receive image placeholders; `route()` throws the actionable `/multimodal-proxy pick` error instead.
|
|
25
|
+
- **Cache-hit `structuredContent.text` now matches fresh calls** — the analyze_image tool cache stores `{ fence, raw }`, so cached requests return the raw analysis text in `structuredContent` (as `AnalyzeImageOutput` advertises) while the model still sees the identical fence.
|
|
26
|
+
|
|
27
|
+
## [1.18.1] - 2026-08-30
|
|
28
|
+
|
|
29
|
+
### Fixed (post-release review of 1.18.0)
|
|
30
|
+
|
|
31
|
+
- **The stage-2 syntax gate was a silent no-op on Windows.** `tools/check-syntax.mjs` compared `walk()` paths (backslash separators) against TypeScript-normalized source-file names (forward slashes), so the `has()` filter dropped every diagnostic — a planted `await`-in-non-async probe passed the gate. Both sides are now normalized; the planted-probe test fails the gate as intended. (Linux CI paths happened to match, so releases were still protected — local/dev Windows runs were not.)
|
|
32
|
+
- **Default-substitution notice no longer fires when the proxy is `off`** — model resolution is irrelevant with media analysis disabled.
|
|
33
|
+
- **The substitution hint is now cause-aware**: when the substituted-from model is missing from the catalog the notice suggests updating pi / pinning a model, instead of the (wrong) `pi --login` key hint that only fits the no-API-key case.
|
|
34
|
+
- **`writePersisted`'s fire-and-forget status refresh gained a `.catch`** — a rejected key probe can no longer surface as an unhandled promise rejection in daemon processes.
|
|
35
|
+
- **`withModelFallback` skips the 5 async key probes for explicit configs** (`PI_VISION_PROXY_MODEL` or persisted `modelExplicit`), where resolution is a no-op by definition.
|
|
36
|
+
|
|
7
37
|
## [1.18.0] - 2026-08-30
|
|
8
38
|
|
|
9
39
|
### Added
|
package/README.md
CHANGED
|
@@ -8,6 +8,15 @@ When **video or audio files** are detected, they are routed to a **multimodal mo
|
|
|
8
8
|
|
|
9
9
|
**YouTube links** are detected too: paste a URL (`youtube.com/watch?v=…`, `youtu.be/…`, `/shorts/…`, etc.) and the video is downloaded with [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) and analyzed exactly like a local file.
|
|
10
10
|
|
|
11
|
+
## What's new in 1.19.0
|
|
12
|
+
|
|
13
|
+
Deep integration with recent Pi releases (0.87 / 0.99):
|
|
14
|
+
|
|
15
|
+
- **Virtual model — `Multimodal Auto`** (Pi ≥ 0.99, on by default): a new entry in `/model` that routes each request for you — turns whose pending input carries images go **natively** to your configured vision model (real pixels, no text fence), everything else stays on your base model (auto = whatever physical model handled the session before, or pin one with `/multimodal-proxy virtual-base`). Sticky routing keeps prompt caches valid within a turn; the footer shows the routed model and `/session` lists cost per physical model. Disable with `/multimodal-proxy virtual off`.
|
|
16
|
+
- **`analyze_image` speaks the 0.99 tool API** — errors now surface as real tool errors (`isError`) instead of fake text, results carry a machine-readable `structuredContent` (analysis text, provider/model, latency, grounding format, per-image ids/dimensions/crop) so **codemode scripts** can consume them programmatically, and the tool is annotated (`readOnlyHint`) and namespaced (`multimodal-proxy`) for codemode listings.
|
|
17
|
+
- **Model-aware upload downscale** (Pi ≥ 0.87) — when the configured vision model declares `inputLimits.images.resize` in its catalog entry, the proxy derives the downscale target, JPEG quality, and byte budget from it (tightening, never loosening, your `max-upload` settings). No more double-resizing against provider limits.
|
|
18
|
+
- **Compaction-proof recall hint** (Pi ≥ 0.87) — the `analyze_image` recall affordance moves to a stable system-layer block via `context_with_system` once media has been described: it survives compaction (the old per-user-message hint was summarized away) and also reaches vision-capable session models, where the strip path never fired. Older hosts keep the previous behavior.
|
|
19
|
+
|
|
11
20
|
## What's new in 1.18.0
|
|
12
21
|
|
|
13
22
|
- **Key-aware default model resolution** — the implicit vision default now lands on a model you can actually call. If the default (`zai/glm-5.3-flash`) has no API key in your setup, the proxy walks a preference-ordered candidate chain — `zai/glm-5.3-flash` → `deepseek/deepseek-v4-flash-vision-exp` (new in Pi 0.84.4, cheap, built-in provider) → `anthropic/claude-sonnet-5` — and picks the first candidate that is in the catalog **and keyed**. A keyed legacy default is never traded for an unkeyed model; explicit choices (`/multimodal-proxy model`, `pick`, `PI_VISION_PROXY_MODEL`) are still never rewritten; and when nothing is keyed you get the actionable `pi --login` hint as before. A substitution is announced once per session.
|
|
@@ -118,6 +127,24 @@ Both are persisted in `~/.pi/agent/multimodal-proxy.json` and can be set via env
|
|
|
118
127
|
| **`always`** | Always uses the proxy, even if the active model supports images |
|
|
119
128
|
| **`off`** | Disabled entirely |
|
|
120
129
|
|
|
130
|
+
## Virtual model (Pi ≥ 0.99)
|
|
131
|
+
|
|
132
|
+
`Multimodal Auto` (`multimodal-proxy/auto`) is a [virtual model](https://pi.dev/docs/latest/virtual-models) the extension registers on Pi 0.99+ (on by default; `/multimodal-proxy virtual off` removes it). Select it once in `/model` and every request is routed for you:
|
|
133
|
+
|
|
134
|
+
| Turn | Routed to |
|
|
135
|
+
|------|-----------|
|
|
136
|
+
| Pending input carries an image attachment | Your configured **vision model** — natively, so the model sees the actual pixels |
|
|
137
|
+
| Everything else | Your **base model**: the pinned `/multimodal-proxy virtual-base` model, else the physical model that handled the session before, else the vision model |
|
|
138
|
+
|
|
139
|
+
Why you'd want it:
|
|
140
|
+
|
|
141
|
+
- **One selection, automatic escalation** — keep a cheap/fast model for text turns and pay for the vision model only when media actually appears. No manual `/model` switching.
|
|
142
|
+
- **Native vision instead of descriptions** — unlike proxy mode (which converts images to text fences for non-vision models), media turns get real pixel fidelity while `analyze_image` recall stays available for re-querying.
|
|
143
|
+
- **Cost transparency for free** — the footer shows `Multimodal Auto → <routed model>`, and `/session` lists cost per physical model.
|
|
144
|
+
- **Cache-safe** — continuations and retries stay on the model that handled the turn, so prompt caches and thinking signatures survive.
|
|
145
|
+
|
|
146
|
+
Virtual-model routing is orthogonal to proxy mode: with `Multimodal Auto` selected, image stripping never applies (the virtual model advertises image input), while video/audio path detection, `analyze_image`, and consent gates keep working exactly as before.
|
|
147
|
+
|
|
121
148
|
## Configuration
|
|
122
149
|
|
|
123
150
|
Settings persist across sessions in `~/.pi/agent/multimodal-proxy.json`. Environment variables override file settings; in-session commands override both.
|
|
@@ -147,6 +174,8 @@ Settings persist across sessions in `~/.pi/agent/multimodal-proxy.json`. Environ
|
|
|
147
174
|
(clear with: /multimodal-proxy fallback-model clear)
|
|
148
175
|
/multimodal-proxy retry <0-5> → retries on transient errors (429/5xx/network), default 2
|
|
149
176
|
/multimodal-proxy max-upload <dim | n mb | off> → downscale uploads larger than this (default 2048 px / 5 MB; off = 8192 px / 20 MB)
|
|
177
|
+
/multimodal-proxy virtual on | off → register/remove the 'Multimodal Auto' virtual model (Pi ≥ 0.99, default on)
|
|
178
|
+
/multimodal-proxy virtual-base <provider/model-id> → pin the text-turn model for the virtual model ('off' clears → auto)
|
|
150
179
|
/multimodal-proxy status on | off → show/hide the steady status line
|
|
151
180
|
/multimodal-proxy grounding-models list → show grounding-capable models
|
|
152
181
|
/multimodal-proxy grounding-models add <provider/id> [--format <fmt>]
|
|
@@ -188,6 +217,8 @@ Legacy alias: /vision-proxy <args> works identically.
|
|
|
188
217
|
| `PI_VISION_PROXY_MAX_UPLOAD_DIM` | `0` (disable downscaling entirely), or 512–8192 px long-edge threshold | `2048` |
|
|
189
218
|
| `PI_VISION_PROXY_MAX_UPLOAD_MB` | 0.5–20 upload byte budget before downscale | `5` |
|
|
190
219
|
| `PI_VISION_PROXY_FALLBACK_MODEL` | `provider/model-id`, or `none`/`off` to clear | not set |
|
|
220
|
+
| `PI_VISION_PROXY_VIRTUAL` | `on`, `off` — register the `Multimodal Auto` virtual model (Pi ≥ 0.99) | `on` |
|
|
221
|
+
| `PI_VISION_PROXY_VIRTUAL_BASE` | `provider/model-id` pinning the virtual model's text-turn model, or `none` to clear | not set |
|
|
191
222
|
| `PI_VISION_PROXY_STATUS_LINE` | `on`, `off` | `on` |
|
|
192
223
|
| `PI_VISION_PROXY_PATH_DETECTION` | `on`, `off` — `off` disables scanning prompt text for media file paths; structured attachments are always processed | `on` |
|
|
193
224
|
|
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
RECALL_HINT,
|
|
99
99
|
createAbortError,
|
|
100
100
|
downscaleForUpload,
|
|
101
|
+
modelImageResizeLimits,
|
|
101
102
|
overUploadDim,
|
|
102
103
|
downscaleTargetDim,
|
|
103
104
|
isAbortError,
|
|
@@ -278,18 +279,18 @@ describe("readEnvOverrides", () => {
|
|
|
278
279
|
|
|
279
280
|
describe("envFlags", () => {
|
|
280
281
|
it("reports presence per variable", () => {
|
|
281
|
-
assert.deepEqual(envFlags({}), { mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false });
|
|
282
|
+
assert.deepEqual(envFlags({}), { mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false, virtual: false, virtualBase: false });
|
|
282
283
|
assert.deepEqual(
|
|
283
284
|
envFlags({
|
|
284
285
|
PI_VISION_PROXY_MODE: "x",
|
|
285
286
|
PI_VISION_PROXY_MODEL: "y",
|
|
286
287
|
PI_VISION_PROXY_INCLUDE_CONTEXT: "",
|
|
287
288
|
}),
|
|
288
|
-
{ mode: true, model: true, context: true, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false },
|
|
289
|
+
{ mode: true, model: true, context: true, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false, virtual: false, virtualBase: false },
|
|
289
290
|
);
|
|
290
291
|
assert.deepEqual(
|
|
291
292
|
envFlags({ PI_VISION_PROXY_ALLOW_HOME: "1", PI_VISION_PROXY_ALLOWED_FOLDERS: "/a" }),
|
|
292
|
-
{ mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: true, allowedFolders: true, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false },
|
|
293
|
+
{ mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: true, allowedFolders: true, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false, virtual: false, virtualBase: false },
|
|
293
294
|
);
|
|
294
295
|
assert.equal(envFlags({ PI_VISION_PROXY_ALLOWED_PROVIDERS: "" }).allowedProviders, true);
|
|
295
296
|
// An unrecognized ALLOW_HOME value is not an override and must not lock the command
|
|
@@ -302,6 +303,21 @@ describe("envFlags", () => {
|
|
|
302
303
|
assert.equal(envFlags({ PI_VISION_PROXY_STATUS_LINE: "bogus" }).statusLine, false);
|
|
303
304
|
assert.equal(envFlags({ PI_VISION_PROXY_YTDLP_COOKIES_FROM_BROWSER: "chrome" }).ytdlpCookies, true);
|
|
304
305
|
assert.equal(envFlags({ PI_VISION_PROXY_YTDLP_EXTRACTOR_ARGS: "youtube:player_client=web" }).ytdlpExtractorArgs, true);
|
|
306
|
+
// 1.19.0 virtual-model overrides
|
|
307
|
+
assert.equal(envFlags({ PI_VISION_PROXY_VIRTUAL: "on" }).virtual, true);
|
|
308
|
+
assert.equal(envFlags({ PI_VISION_PROXY_VIRTUAL: "bogus" }).virtual, false);
|
|
309
|
+
assert.equal(envFlags({ PI_VISION_PROXY_VIRTUAL_BASE: "zai/glm-5.3-flash" }).virtualBase, true);
|
|
310
|
+
assert.equal(envFlags({ PI_VISION_PROXY_VIRTUAL_BASE: "none" }).virtualBase, true);
|
|
311
|
+
assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_VIRTUAL: "off" }).virtualModel, "off");
|
|
312
|
+
assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_VIRTUAL: "bogus" }).virtualModel, undefined);
|
|
313
|
+
assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_VIRTUAL_BASE: "zai/glm-5.3-flash" }), {
|
|
314
|
+
virtualBaseProvider: "zai",
|
|
315
|
+
virtualBaseModelId: "glm-5.3-flash",
|
|
316
|
+
});
|
|
317
|
+
assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_VIRTUAL_BASE: "none" }), {
|
|
318
|
+
virtualBaseProvider: undefined,
|
|
319
|
+
virtualBaseModelId: undefined,
|
|
320
|
+
});
|
|
305
321
|
});
|
|
306
322
|
});
|
|
307
323
|
|
|
@@ -3480,6 +3496,109 @@ describe("1.16.0 downscaleForUpload (integration)", () => {
|
|
|
3480
3496
|
});
|
|
3481
3497
|
});
|
|
3482
3498
|
|
|
3499
|
+
describe("1.19.0 model image limits (pi ≥ 0.87 inputLimits.images.resize)", () => {
|
|
3500
|
+
it("modelImageResizeLimits returns null without a declaration", () => {
|
|
3501
|
+
assert.equal(modelImageResizeLimits(undefined), null);
|
|
3502
|
+
assert.equal(modelImageResizeLimits({}), null);
|
|
3503
|
+
assert.equal(modelImageResizeLimits({ inputLimits: {} }), null);
|
|
3504
|
+
assert.equal(modelImageResizeLimits({ inputLimits: { images: {} } }), null);
|
|
3505
|
+
// No numeric dimension → no usable target
|
|
3506
|
+
assert.equal(modelImageResizeLimits({ inputLimits: { images: { resize: { jpegQuality: 75 } } } }), null);
|
|
3507
|
+
});
|
|
3508
|
+
|
|
3509
|
+
it("modelImageResizeLimits derives a long-edge target, quality, and raw-byte budget", () => {
|
|
3510
|
+
const limits = modelImageResizeLimits({
|
|
3511
|
+
inputLimits: { images: { resize: { maxWidth: 1568, maxHeight: 1040, jpegQuality: 75, maxBytes: 524288 } } },
|
|
3512
|
+
});
|
|
3513
|
+
assert.equal(limits?.targetDim, 1040);
|
|
3514
|
+
assert.equal(limits?.quality, 75);
|
|
3515
|
+
assert.equal(limits?.maxBytes, 393216); // 524288 × 0.75
|
|
3516
|
+
const partial = modelImageResizeLimits({ inputLimits: { images: { resize: { maxWidth: 1568 } } } });
|
|
3517
|
+
assert.equal(partial?.targetDim, 1568);
|
|
3518
|
+
assert.equal(partial?.quality, 88);
|
|
3519
|
+
assert.equal(partial?.maxBytes, undefined);
|
|
3520
|
+
});
|
|
3521
|
+
|
|
3522
|
+
it("maxBytes-only declarations are honored — targetDim stays unset, budget and quality tighten (PR #34 review)", () => {
|
|
3523
|
+
const limits = modelImageResizeLimits({
|
|
3524
|
+
inputLimits: { images: { resize: { maxBytes: 524288, jpegQuality: 70 } } },
|
|
3525
|
+
});
|
|
3526
|
+
assert.notEqual(limits, null);
|
|
3527
|
+
assert.equal(limits?.targetDim, undefined);
|
|
3528
|
+
assert.equal(limits?.quality, 70);
|
|
3529
|
+
assert.equal(limits?.maxBytes, 393216);
|
|
3530
|
+
// A tiny maxBytes never collapses to a zero budget
|
|
3531
|
+
assert.equal(modelImageResizeLimits({ inputLimits: { images: { resize: { maxBytes: 1 } } } })?.maxBytes, 1);
|
|
3532
|
+
});
|
|
3533
|
+
|
|
3534
|
+
it("maxBytes-only limits trigger the byte budget on downscaleForUpload (PR #34 review)", async () => {
|
|
3535
|
+
const { Image } = await import("imagescript");
|
|
3536
|
+
const noise = new Image(200, 150);
|
|
3537
|
+
for (let x = 1; x <= 200; x++) {
|
|
3538
|
+
for (let y = 1; y <= 150; y++) {
|
|
3539
|
+
noise.setPixelAt(x, y, (Math.random() * 0xffffffff) >>> 0);
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
const img = bufferToPiAiImage(Buffer.from(await noise.encode(1)), "image/png");
|
|
3543
|
+
// Dims are inside every dim threshold; only the model's byte budget
|
|
3544
|
+
// (2048 raw) is exceeded by the ~100 KB noise PNG → must re-encode.
|
|
3545
|
+
const out = await downscaleForUpload(
|
|
3546
|
+
img,
|
|
3547
|
+
{ maxUploadDim: 2048, maxUploadBytes: 5 * 1024 * 1024 },
|
|
3548
|
+
{ quality: 75, maxBytes: 2048 },
|
|
3549
|
+
);
|
|
3550
|
+
assert.equal(out.mimeType, "image/jpeg");
|
|
3551
|
+
});
|
|
3552
|
+
|
|
3553
|
+
it("model limits tighten downscaleForUpload below the user config", async () => {
|
|
3554
|
+
const { Image } = await import("imagescript");
|
|
3555
|
+
const big = bufferToPiAiImage(Buffer.from(await new Image(3000, 200).encode(1)), "image/png");
|
|
3556
|
+
const out = await downscaleForUpload(
|
|
3557
|
+
big,
|
|
3558
|
+
{ maxUploadDim: 2048, maxUploadBytes: 5 * 1024 * 1024 },
|
|
3559
|
+
{ targetDim: 1024, quality: 75 },
|
|
3560
|
+
);
|
|
3561
|
+
assert.equal(out.mimeType, "image/jpeg");
|
|
3562
|
+
const dims = extractDimensions(piAiImageToBuffer(out));
|
|
3563
|
+
assert.equal(dims?.width, 1024);
|
|
3564
|
+
});
|
|
3565
|
+
|
|
3566
|
+
it("model limits never re-enable a user-disabled downscale", async () => {
|
|
3567
|
+
const { Image } = await import("imagescript");
|
|
3568
|
+
const big = bufferToPiAiImage(Buffer.from(await new Image(3000, 200).encode(1)), "image/png");
|
|
3569
|
+
const out = await downscaleForUpload(
|
|
3570
|
+
big,
|
|
3571
|
+
{ maxUploadDim: 0, maxUploadBytes: 5 * 1024 * 1024 },
|
|
3572
|
+
{ targetDim: 1024, quality: 75 },
|
|
3573
|
+
);
|
|
3574
|
+
assert.equal(out, big);
|
|
3575
|
+
});
|
|
3576
|
+
|
|
3577
|
+
it("model limits under the user config do not shrink an in-budget image", async () => {
|
|
3578
|
+
const { Image } = await import("imagescript");
|
|
3579
|
+
const small = bufferToPiAiImage(Buffer.from(await new Image(800, 600).encode(1)), "image/png");
|
|
3580
|
+
const out = await downscaleForUpload(
|
|
3581
|
+
small,
|
|
3582
|
+
{ maxUploadDim: 2048, maxUploadBytes: 5 * 1024 * 1024 },
|
|
3583
|
+
{ targetDim: 1024, quality: 75 },
|
|
3584
|
+
);
|
|
3585
|
+
assert.equal(out, small);
|
|
3586
|
+
});
|
|
3587
|
+
|
|
3588
|
+
it("sanitize validates the 1.19.0 virtual-model fields", () => {
|
|
3589
|
+
assert.equal(sanitize({ ...DEFAULT_CONFIG }).virtualModel, "on");
|
|
3590
|
+
assert.equal(sanitize({ ...DEFAULT_CONFIG, virtualModel: "off" }).virtualModel, "off");
|
|
3591
|
+
assert.equal(sanitize({ ...DEFAULT_CONFIG, virtualModel: "bogus" as any }).virtualModel, "on");
|
|
3592
|
+
const pinned = sanitize({ ...DEFAULT_CONFIG, virtualBaseProvider: "x-ai", virtualBaseModelId: "some-model" });
|
|
3593
|
+
assert.equal(pinned.virtualBaseProvider, "xai");
|
|
3594
|
+
assert.equal(pinned.virtualBaseModelId, "some-model");
|
|
3595
|
+
// Both halves required — a lone provider is dropped
|
|
3596
|
+
const lone = sanitize({ ...DEFAULT_CONFIG, virtualBaseProvider: "zai" });
|
|
3597
|
+
assert.equal("virtualBaseProvider" in lone, false);
|
|
3598
|
+
assert.equal("virtualBaseModelId" in lone, false);
|
|
3599
|
+
});
|
|
3600
|
+
});
|
|
3601
|
+
|
|
3483
3602
|
describe("1.16.0 review fixes (PR #27)", () => {
|
|
3484
3603
|
it("createAbortError is abort-shaped with the exact 'aborted' message", () => {
|
|
3485
3604
|
const err = createAbortError();
|
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,
|
|
@@ -2606,7 +2656,7 @@ parentPort.on("message", async (task) => {
|
|
|
2606
2656
|
const w = Math.max(1, Math.round(img.width * scale));
|
|
2607
2657
|
const h = Math.max(1, Math.round(img.height * scale));
|
|
2608
2658
|
// 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);
|
|
2659
|
+
encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(task.quality ?? 88);
|
|
2610
2660
|
} else {
|
|
2611
2661
|
const cropped = img.crop(task.crop.x, task.crop.y, task.crop.width, task.crop.height);
|
|
2612
2662
|
encoded = mimeType === "image/png" ? await cropped.encode(1) : await cropped.encodeJPEG(90);
|
|
@@ -2873,7 +2923,7 @@ export function overUploadDim(
|
|
|
2873
2923
|
}
|
|
2874
2924
|
|
|
2875
2925
|
/** In-thread decode → resize → JPEG encode, mirroring cropInThread. */
|
|
2876
|
-
async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
|
|
2926
|
+
async function resizeInThread(imageBytes: Buffer, targetDim: number, quality = 88): Promise<Buffer | null> {
|
|
2877
2927
|
const img = await decodeWithTimeout(imageBytes);
|
|
2878
2928
|
if (img.width > MAX_IMAGE_DIMENSION || img.height > MAX_IMAGE_DIMENSION) {
|
|
2879
2929
|
return null;
|
|
@@ -2881,7 +2931,7 @@ async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Bu
|
|
|
2881
2931
|
const scale = Math.min(targetDim / img.width, targetDim / img.height, 1);
|
|
2882
2932
|
const w = Math.max(1, Math.round(img.width * scale));
|
|
2883
2933
|
const h = Math.max(1, Math.round(img.height * scale));
|
|
2884
|
-
const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(
|
|
2934
|
+
const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(quality);
|
|
2885
2935
|
return Buffer.from(encoded);
|
|
2886
2936
|
}
|
|
2887
2937
|
|
|
@@ -2890,6 +2940,7 @@ async function resizeInWorker(
|
|
|
2890
2940
|
imageBytes: Buffer,
|
|
2891
2941
|
targetDim: number,
|
|
2892
2942
|
timeoutMs: number,
|
|
2943
|
+
quality?: number,
|
|
2893
2944
|
): Promise<Buffer | null | typeof WORKER_UNAVAILABLE> {
|
|
2894
2945
|
if (!(await ensureWorkerInfra())) return WORKER_UNAVAILABLE;
|
|
2895
2946
|
|
|
@@ -2904,7 +2955,7 @@ async function resizeInWorker(
|
|
|
2904
2955
|
|
|
2905
2956
|
const { result, reusable } = await runCropTask(
|
|
2906
2957
|
worker,
|
|
2907
|
-
{ op: "resize", bytes: ab, targetDim, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
|
|
2958
|
+
{ op: "resize", bytes: ab, targetDim, quality, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
|
|
2908
2959
|
timeoutMs,
|
|
2909
2960
|
);
|
|
2910
2961
|
if (reusable) releaseWorker(worker);
|
|
@@ -2913,32 +2964,97 @@ async function resizeInWorker(
|
|
|
2913
2964
|
}
|
|
2914
2965
|
|
|
2915
2966
|
/**
|
|
2916
|
-
* Downscale image bytes so the long edge fits targetDim, re-encoding as
|
|
2917
|
-
*
|
|
2918
|
-
* original bytes rather than failing the whole analysis.
|
|
2967
|
+
* Downscale image bytes so the long edge fits targetDim, re-encoding as JPEG
|
|
2968
|
+
* (quality 88 unless overridden). Returns null on decode/encode failure —
|
|
2969
|
+
* callers then send the original bytes rather than failing the whole analysis.
|
|
2919
2970
|
*/
|
|
2920
|
-
export async function downscaleImage(
|
|
2971
|
+
export async function downscaleImage(
|
|
2972
|
+
imageBytes: Buffer,
|
|
2973
|
+
targetDim: number,
|
|
2974
|
+
quality = 88,
|
|
2975
|
+
): Promise<Buffer | null> {
|
|
2921
2976
|
try {
|
|
2922
2977
|
const dims = extractDimensions(imageBytes);
|
|
2923
2978
|
if (dims && (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION)) {
|
|
2924
2979
|
return null;
|
|
2925
2980
|
}
|
|
2926
2981
|
if (decodeWorkerEnabled()) {
|
|
2927
|
-
const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs());
|
|
2982
|
+
const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs(), quality);
|
|
2928
2983
|
if (viaWorker !== WORKER_UNAVAILABLE) return viaWorker;
|
|
2929
2984
|
// else: worker infra unavailable — fall through to the in-thread path
|
|
2930
2985
|
}
|
|
2931
|
-
return await resizeInThread(imageBytes, targetDim);
|
|
2986
|
+
return await resizeInThread(imageBytes, targetDim, quality);
|
|
2932
2987
|
} catch {
|
|
2933
2988
|
return null;
|
|
2934
2989
|
}
|
|
2935
2990
|
}
|
|
2936
2991
|
|
|
2992
|
+
/**
|
|
2993
|
+
* Per-model image upload limits declared on a catalog entry (pi ≥ 0.87):
|
|
2994
|
+
* `inputLimits.images.resize` = { maxWidth, maxHeight, maxBytes, jpegQuality }.
|
|
2995
|
+
* `maxBytes` limits the base64 payload; here it is converted to a raw-byte
|
|
2996
|
+
* budget (~3/4) so it can share the raw-byte comparison of the user config.
|
|
2997
|
+
* Returns null when the model declares nothing (or an older pi host).
|
|
2998
|
+
*/
|
|
2999
|
+
export interface ModelImageResizeLimits {
|
|
3000
|
+
/** 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). */
|
|
3001
|
+
targetDim?: number;
|
|
3002
|
+
/** JPEG re-encode quality (default 88). */
|
|
3003
|
+
quality: number;
|
|
3004
|
+
/** Raw-byte budget derived from the base64 maxBytes, when declared. */
|
|
3005
|
+
maxBytes?: number;
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
export function modelImageResizeLimits(model: unknown): ModelImageResizeLimits | null {
|
|
3009
|
+
const resize = (
|
|
3010
|
+
model as
|
|
3011
|
+
| {
|
|
3012
|
+
inputLimits?: {
|
|
3013
|
+
images?: {
|
|
3014
|
+
resize?: {
|
|
3015
|
+
maxWidth?: unknown;
|
|
3016
|
+
maxHeight?: unknown;
|
|
3017
|
+
maxBytes?: unknown;
|
|
3018
|
+
jpegQuality?: unknown;
|
|
3019
|
+
};
|
|
3020
|
+
};
|
|
3021
|
+
};
|
|
3022
|
+
}
|
|
3023
|
+
| null
|
|
3024
|
+
| undefined
|
|
3025
|
+
)?.inputLimits?.images?.resize;
|
|
3026
|
+
if (!resize) return null;
|
|
3027
|
+
const maxW = typeof resize.maxWidth === "number" && resize.maxWidth > 0 ? resize.maxWidth : undefined;
|
|
3028
|
+
const maxH = typeof resize.maxHeight === "number" && resize.maxHeight > 0 ? resize.maxHeight : undefined;
|
|
3029
|
+
const dims = [maxW, maxH].filter((d): d is number => d !== undefined);
|
|
3030
|
+
const quality =
|
|
3031
|
+
typeof resize.jpegQuality === "number" && resize.jpegQuality >= 1 && resize.jpegQuality <= 100
|
|
3032
|
+
? Math.round(resize.jpegQuality)
|
|
3033
|
+
: 88;
|
|
3034
|
+
const maxBytes =
|
|
3035
|
+
typeof resize.maxBytes === "number" && resize.maxBytes > 0
|
|
3036
|
+
? Math.max(1, Math.floor(resize.maxBytes * 0.75))
|
|
3037
|
+
: undefined;
|
|
3038
|
+
// A declaration must carry at least one meaningful limit — dimensions or a
|
|
3039
|
+
// byte budget. Quality alone changes nothing until a resize triggers, so a
|
|
3040
|
+
// quality-only declaration is treated as absent. A maxBytes-only
|
|
3041
|
+
// declaration is honored (PR #34 review): targetDim stays unset and only
|
|
3042
|
+
// the byte budget and quality tighten the user's thresholds.
|
|
3043
|
+
if (dims.length === 0 && maxBytes === undefined) return null;
|
|
3044
|
+
return {
|
|
3045
|
+
targetDim: dims.length > 0 ? Math.min(...dims) : undefined,
|
|
3046
|
+
quality,
|
|
3047
|
+
maxBytes,
|
|
3048
|
+
};
|
|
3049
|
+
}
|
|
3050
|
+
|
|
2937
3051
|
/**
|
|
2938
3052
|
* Downscale an image for upload when it exceeds the configured thresholds
|
|
2939
|
-
* (1.16.0).
|
|
2940
|
-
*
|
|
2941
|
-
*
|
|
3053
|
+
* (1.16.0). Model-declared limits (pi ≥ 0.87 `inputLimits.images.resize`,
|
|
3054
|
+
* 1.19.0) tighten the user's thresholds; they never re-enable a downscale the
|
|
3055
|
+
* user disabled with `maxUploadDim: 0`. Returns the original image untouched
|
|
3056
|
+
* when it is small enough or when downscaling fails for any reason — upload
|
|
3057
|
+
* downscale is best-effort cost/limit protection, never a hard failure.
|
|
2942
3058
|
*
|
|
2943
3059
|
* Dimension-triggered resizes are accepted even when the JPEG re-encode grows
|
|
2944
3060
|
* the payload slightly (flat-color PNGs compress better than JPEG) — the pixel
|
|
@@ -2948,13 +3064,23 @@ export async function downscaleImage(imageBytes: Buffer, targetDim: number): Pro
|
|
|
2948
3064
|
export async function downscaleForUpload(
|
|
2949
3065
|
img: PiAiImage,
|
|
2950
3066
|
config: Pick<VisionConfig, "maxUploadDim" | "maxUploadBytes">,
|
|
3067
|
+
modelLimits?: ModelImageResizeLimits | null,
|
|
2951
3068
|
): Promise<PiAiImage> {
|
|
2952
3069
|
if (config.maxUploadDim === 0) return img; // downscaling disabled
|
|
3070
|
+
const targetDim = modelLimits?.targetDim
|
|
3071
|
+
? Math.min(config.maxUploadDim, modelLimits.targetDim)
|
|
3072
|
+
: config.maxUploadDim;
|
|
3073
|
+
const maxUploadBytes =
|
|
3074
|
+
modelLimits?.maxBytes !== undefined
|
|
3075
|
+
? Math.min(config.maxUploadBytes, modelLimits.maxBytes)
|
|
3076
|
+
: config.maxUploadBytes;
|
|
3077
|
+
const quality = modelLimits?.quality ?? 88;
|
|
3078
|
+
const effective = { maxUploadDim: targetDim, maxUploadBytes };
|
|
2953
3079
|
const buf = piAiImageToBuffer(img);
|
|
2954
3080
|
const dims = extractDimensions(buf);
|
|
2955
|
-
const overDim = overUploadDim(dims,
|
|
2956
|
-
if (downscaleTargetDim(dims, buf.byteLength,
|
|
2957
|
-
const resized = await downscaleImage(buf,
|
|
3081
|
+
const overDim = overUploadDim(dims, effective);
|
|
3082
|
+
if (downscaleTargetDim(dims, buf.byteLength, effective) === null) return img;
|
|
3083
|
+
const resized = await downscaleImage(buf, targetDim, quality);
|
|
2958
3084
|
if (!resized) return img;
|
|
2959
3085
|
if (!overDim && resized.byteLength >= buf.byteLength) return img;
|
|
2960
3086
|
return bufferToPiAiImage(resized, "image/jpeg");
|