Merge pull request #1315 from cdanis/feat/no-webp-llama-cpp

feat: add OMP_NO_WEBP env var to exclude WebP from image resize
This commit is contained in:
Can Bölük
2026-05-25 13:00:53 +03:00
committed by GitHub
3 changed files with 65 additions and 27 deletions
+4 -1
View File
@@ -2,12 +2,15 @@
## [Unreleased]
### Fixed
### Added
- Added `OMP_NO_WEBP` environment variable to disable WebP encoding in image resize, fixing HTTP 400 errors when attaching browser snapshots to vision models running on local llama.cpp (which uses STB library that lacks WebP support)
- Fixed loop mode submitting the next prompt while a background async-job delivery turn (idle flush) was still pending, which could cause the job result to be silently dropped and make the session appear to keep firing while work was ongoing ([#1294](https://github.com/can1357/oh-my-pi/issues/1294))
- Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280))
- Fixed append-only context mode not being recomputed after model switches — the mode was frozen at session construction time using the initial model's provider, so `provider.appendOnlyContext=auto` left append-only enabled after switching away from DeepSeek (or disabled after switching to DeepSeek) for the rest of the session
### Fixed
- Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280))
## [15.2.4] - 2026-05-22
### Breaking Changes
+51 -26
View File
@@ -5,6 +5,7 @@ export interface ImageResizeOptions {
maxHeight?: number;
maxBytes?: number;
jpegQuality?: number;
excludeWebP?: boolean;
}
export interface ResizedImage {
@@ -29,6 +30,7 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
maxHeight: 1568,
maxBytes: DEFAULT_MAX_BYTES,
jpegQuality: 80,
excludeWebP: Bun.env.OMP_NO_WEBP !== undefined,
};
/** Pick the smallest of N encoded buffers. */
@@ -49,11 +51,13 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
*
* Strategy:
* 1. Probe metadata. If already within all limits, return original.
* 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG/WebP — return smallest.
* 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG (+ WebP) — return smallest.
* 3. If still too large, walk a lossy JPEG/WebP quality ladder.
* 4. If still too large, walk a dimension-scale ladder × quality ladder.
* 5. If still too large, return the smallest variant produced.
*
* Set OMP_NO_WEBP to exclude WebP from encoding (llama.cpp STB doesn't decode it).
*
* Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
* off the JS thread when the terminal (`.bytes()`) is awaited.
*/
@@ -99,44 +103,65 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
targetHeight = opts.maxHeight;
}
// First-attempt encoder: try PNG, JPEG, and lossy WebP — return whichever is smallest.
// PNG wins for line art / few-color UI; JPEG and WebP win for photographic content;
// WebP usually beats JPEG by 25–35% at the same perceptual quality.
// First-attempt encoder: try PNG and JPEG (+ WebP if not excluded) — return smallest.
// PNG wins for line art / few-color UI; JPEG wins for photographic content;
// WebP usually beats JPEG by 25–35% but is disabled when OMP_NO_WEBP is set
// because many local inference backends (llama.cpp STB) don't decode it.
async function encodeSmallest(
width: number,
height: number,
quality: number,
): Promise<{ buffer: Uint8Array; mimeType: string }> {
const [pngBuffer, jpegBuffer, webpBuffer] = await Promise.all([
new Bun.Image(inputBuffer).resize(width, height).png().bytes(),
new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
const candidates = await Promise.all([
new Bun.Image(inputBuffer)
.resize(width, height)
.png()
.bytes()
.then(b => ({ buffer: b, mimeType: "image/png" })),
new Bun.Image(inputBuffer)
.resize(width, height)
.jpeg({ quality })
.bytes()
.then(b => ({ buffer: b, mimeType: "image/jpeg" })),
...(opts.excludeWebP
? []
: [
new Bun.Image(inputBuffer)
.resize(width, height)
.webp({ quality })
.bytes()
.then(b => ({ buffer: b, mimeType: "image/webp" })),
]),
]);
return pickSmallest(
{ buffer: pngBuffer, mimeType: "image/png" },
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
{ buffer: webpBuffer, mimeType: "image/webp" },
);
return pickSmallest(...candidates);
}
// Lossy-only encoder — used in quality/dimension fallback ladders where PNG can't shrink
// further (PNG quality is a no-op). Picks the smaller of JPEG vs lossy WebP at the
// requested quality.
// Lossy encoder for quality/dimension fallback ladders. PNG is excluded since
// it's lossless and doesn't respond to quality parameters. WebP is included
// unless OMP_NO_WEBP is set (llama.cpp STB incompatibility).
async function encodeLossy(
width: number,
height: number,
quality: number,
): Promise<{ buffer: Uint8Array; mimeType: string }> {
const [jpegBuffer, webpBuffer] = await Promise.all([
new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
const candidates = await Promise.all([
new Bun.Image(inputBuffer)
.resize(width, height)
.jpeg({ quality })
.bytes()
.then(b => ({ buffer: b, mimeType: "image/jpeg" })),
...(opts.excludeWebP
? []
: [
new Bun.Image(inputBuffer)
.resize(width, height)
.webp({ quality })
.bytes()
.then(b => ({ buffer: b, mimeType: "image/webp" })),
]),
]);
return pickSmallest(
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
{ buffer: webpBuffer, mimeType: "image/webp" },
);
return pickSmallest(...candidates);
}
// Quality ladder — more aggressive steps for tighter budgets
const qualitySteps = [70, 60, 50, 40];
const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
@@ -145,7 +170,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
let finalWidth = targetWidth;
let finalHeight = targetHeight;
// First attempt: resize to target, try PNG/JPEG/WebP, pick smallest
// First attempt: resize to target, try PNG/JPEG (+ WebP), pick smallest
best = await encodeSmallest(targetWidth, targetHeight, opts.jpegQuality);
if (best.buffer.length <= opts.maxBytes) {
@@ -163,7 +188,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
};
}
// Still too large — lossy ladder (JPEG vs WebP, smallest wins) with decreasing quality
// Still too large — lossy JPEG (+ WebP) ladder with decreasing quality
for (const quality of qualitySteps) {
best = await encodeLossy(targetWidth, targetHeight, quality);
@@ -81,4 +81,14 @@ describe("resizeImage defaults", () => {
expect(["image/jpeg", "image/webp"]).toContain(result.mimeType);
expect(result.buffer.length).toBeLessThanOrEqual(500 * 1024);
});
it("excludes WebP when excludeWebP option is true", async () => {
const data = await makeRedPng(2000, 2000);
const result = await resizeImage({ type: "image", data, mimeType: "image/png" }, { excludeWebP: true });
expect(result.wasResized).toBe(true);
expect(["image/png", "image/jpeg"]).toContain(result.mimeType);
expect(result.mimeType).not.toBe("image/webp");
});
});