feat: Bun 1.3.14
- Raised the Bun minimum version to >=1.3.14 across package metadata, install scripts, and changelog notes. - Removed the Photon native image pipeline and added SIXEL-based `sixel` support in pi-natives. - Migrated coding-agent image handling and resizing to `Bun.Image`, including updated tests and a JPEG quality bump to 80. - Added HTTP/2 fetch bootstrap with HTTPS-only fallback and updated Bun build flags for autoload suppression/`--keep-names`.
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added middle elision for streaming tool outputs (bash, ssh, python, js eval) and post-execution tool result spill. When `tools.artifactHeadBytes` is set (default 20 KB), large outputs now keep both the first N KB and the last N KB with an inline `[… N lines elided (M KB) …]` marker between them, instead of dropping everything before the trailing tail. Setting `tools.artifactHeadBytes = 0` reverts to the previous tail-only behavior. The full output is still mirrored to the session artifact (`artifact://<id>`) regardless of elision mode. Exposes `truncateMiddle` and `formatMiddleElisionMarker` from `@oh-my-pi/pi-coding-agent/session/streaming-output`, extends `OutputSinkOptions` with `headBytes`, and adds `direction: "middle"` plus `headRange` / `tailRange` / `elidedLines` / `elidedBytes` to `TruncationMeta`.
|
||||
- Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://<id>`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable.
|
||||
- Added Bun HTTP/2 fetch opt-in. Dev scripts (`bun run dev`, `bun run stats`) now pass `bun --experimental-http2-fetch` so every `fetch()` advertises `h2` in the TLS ALPN list and falls back to HTTP/1.1 when the server doesn't select it. Multiplexing collapses parallel requests to the same origin onto one TLS connection. For the installed `omp` binary, export `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1` in your shell to enable the same behavior (the flag has to be set before Bun starts; `process.env` from inside JS is too late). Requires Bun **1.3.14**.
|
||||
|
||||
### Changed
|
||||
|
||||
- Raised the image downscaling default JPEG quality from 75 to 80 in `resizeImage` output generation
|
||||
- Changed image resize metadata notes from coordinate-scale hints to a simple `Image resized from <original> to <displayed>` message and hide the note when the resized dimensions are unchanged
|
||||
- Removed `utils/image-convert.ts` and its `convertToPng` helper; callers now inline `new Bun.Image(bytes).png().toBase64()` from [`Bun.Image`](https://bun.com/docs/runtime/image) (Bun 1.3.14+).
|
||||
- Changed image decode/resize/encode in `utils/image-resize.ts` from the native `PhotonImage` binding to [`Bun.Image`](https://bun.com/docs/runtime/image). Same PNG/JPEG/WebP quality+dimension ladder, but pipelines run off-thread on Bun's statically-linked codecs with no native-addon round-trip. Bumped the minimum Bun runtime requirement to **1.3.14**.
|
||||
- Changed `search` pagination in multi-file scopes so `skip` now skips entire files and pages results in groups of up to 20 files, with output guiding the next `skip` value via `Showing files X-Y of N`
|
||||
- Changed multi-file search result selection to cap each file at 20 matches and round-robin across files, so one noisy file no longer suppresses visibility of hits in other files and truncation now reports per-file limits
|
||||
- Changed search truncation metadata/renderer output from match/result-based limits to file-based limits (`fileLimitReached`, `perFileLimitReached`) and updated truncation labels accordingly
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"@types/bun": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3.7"
|
||||
"bun": ">=1.3.14"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
|
||||
@@ -33,6 +33,11 @@ async function main(): Promise<void> {
|
||||
"bun",
|
||||
"build",
|
||||
"--compile",
|
||||
"--no-compile-autoload-bunfig",
|
||||
"--no-compile-autoload-dotenv",
|
||||
"--no-compile-autoload-tsconfig",
|
||||
"--no-compile-autoload-package-json",
|
||||
"--keep-names",
|
||||
"--define",
|
||||
'process.env.PI_COMPILED="true"',
|
||||
"--external",
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
#!/usr/bin/env bun
|
||||
import { installH2Fetch } from "@oh-my-pi/pi-ai";
|
||||
import { APP_NAME, MIN_BUN_VERSION, procmgr, VERSION } from "@oh-my-pi/pi-utils";
|
||||
|
||||
// Activate HTTP/2 for all `fetch()` calls (provider streams, OAuth, model
|
||||
// discovery, web tools). Bun's HTTP/2 client is gated on a startup flag we
|
||||
// can't toggle from JS, so we patch globalThis.fetch to pass
|
||||
// `protocol: "http2"` per request, with transparent HTTP/1.1 fallback on
|
||||
// `HTTP2Unsupported`. See @oh-my-pi/pi-ai/utils/h2-fetch for details.
|
||||
installH2Fetch();
|
||||
|
||||
// Strip macOS malloc-stack-logging env vars before any subprocess is spawned.
|
||||
// Otherwise every child bun process (subagents, plugin installs, ptree spawns,
|
||||
// etc.) prints a `MallocStackLogging: can't turn off …` warning to stderr.
|
||||
|
||||
@@ -4,7 +4,6 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
|
||||
import { settings } from "../../config/settings";
|
||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
||||
import { resolveImageOptions } from "../../tools/render-utils";
|
||||
import { convertToPng } from "../../utils/image-convert";
|
||||
|
||||
/**
|
||||
* Component that renders a complete assistant message
|
||||
@@ -76,14 +75,15 @@ export class AssistantMessageComponent extends Container {
|
||||
const key = `${toolCallId}:${index}`;
|
||||
if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
|
||||
this.#kittyConversionsInFlight.add(key);
|
||||
convertToPng(image.data, image.mimeType)
|
||||
.then(converted => {
|
||||
new Bun.Image(Buffer.from(image.data, "base64"))
|
||||
.png()
|
||||
.toBase64()
|
||||
.then(data => {
|
||||
this.#kittyConversionsInFlight.delete(key);
|
||||
if (!converted) return;
|
||||
this.#convertedKittyImages.set(key, {
|
||||
type: "image",
|
||||
data: converted.data,
|
||||
mimeType: converted.mimeType,
|
||||
data,
|
||||
mimeType: "image/png",
|
||||
});
|
||||
if (this.#lastMessage) {
|
||||
this.updateContent(this.#lastMessage);
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
|
||||
import { toolRenderers } from "../../tools/renderers";
|
||||
import { renderStatusLine } from "../../tui";
|
||||
import { convertToPng } from "../../utils/image-convert";
|
||||
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
||||
import { renderDiff } from "./diff";
|
||||
|
||||
@@ -295,13 +294,13 @@ export class ToolExecutionComponent extends Container {
|
||||
|
||||
// Convert async - catch errors from processing
|
||||
const index = i;
|
||||
convertToPng(img.data, img.mimeType)
|
||||
.then(converted => {
|
||||
if (converted) {
|
||||
this.#convertedImages.set(index, converted);
|
||||
this.#updateDisplay();
|
||||
this.#ui.requestRender();
|
||||
}
|
||||
new Bun.Image(Buffer.from(img.data, "base64"))
|
||||
.png()
|
||||
.toBase64()
|
||||
.then(data => {
|
||||
this.#convertedImages.set(index, { data, mimeType: "image/png" });
|
||||
this.#updateDisplay();
|
||||
this.#ui.requestRender();
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore conversion failures - display will use original image format
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ImageFormat, PhotonImage } from "@oh-my-pi/pi-natives";
|
||||
|
||||
/**
|
||||
* Convert image to PNG format for terminal display.
|
||||
* Kitty graphics protocol requires PNG format (f=100).
|
||||
*/
|
||||
export async function convertToPng(
|
||||
base64Data: string,
|
||||
mimeType: string,
|
||||
): Promise<{ data: string; mimeType: string } | null> {
|
||||
// Already PNG, no conversion needed
|
||||
if (mimeType === "image/png") {
|
||||
return { data: base64Data, mimeType };
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await PhotonImage.parse(new Uint8Array(Buffer.from(base64Data, "base64")));
|
||||
const pngBuffer = await image.encode(ImageFormat.PNG, 100);
|
||||
return {
|
||||
data: Buffer.from(pngBuffer).toBase64(),
|
||||
mimeType: "image/png",
|
||||
};
|
||||
} catch {
|
||||
// Conversion failed
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import * as fs from "node:fs/promises";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@oh-my-pi/pi-utils";
|
||||
import { resolveReadPath } from "../tools/path-utils";
|
||||
import { convertToPng } from "./image-convert";
|
||||
import { formatDimensionNote, resizeImage } from "./image-resize";
|
||||
|
||||
export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
|
||||
@@ -42,8 +41,13 @@ export async function ensureSupportedImageInput(image: ImageContent): Promise<Im
|
||||
if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
|
||||
return image;
|
||||
}
|
||||
const converted = await convertToPng(image.data, image.mimeType);
|
||||
return converted ? { type: "image", data: converted.data, mimeType: converted.mimeType } : null;
|
||||
try {
|
||||
const bytes = Buffer.from(image.data, "base64");
|
||||
const data = await new Bun.Image(bytes).png().toBase64();
|
||||
return { type: "image", data, mimeType: "image/png" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null> {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { ImageFormat, PhotonImage, SamplingFilter } from "@oh-my-pi/pi-natives";
|
||||
|
||||
export interface ImageResizeOptions {
|
||||
maxWidth?: number; // Default: 1568
|
||||
maxHeight?: number; // Default: 1568
|
||||
maxBytes?: number; // Default: 500KB
|
||||
jpegQuality?: number; // Default: 75
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
maxBytes?: number;
|
||||
jpegQuality?: number;
|
||||
}
|
||||
|
||||
export interface ResizedImage {
|
||||
@@ -24,12 +23,12 @@ export interface ResizedImage {
|
||||
const DEFAULT_MAX_BYTES = 500 * 1024;
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
|
||||
// 1568px — Anthropic downscales anything larger; OpenAI tiles at 768px;
|
||||
// sending bigger pixels wastes bandwidth the model never sees.
|
||||
// Anthropic's "internal recommended size" — Claude internally caps images at
|
||||
// 1568px on the longest edge before vision processing.
|
||||
maxWidth: 1568,
|
||||
maxHeight: 1568,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
jpegQuality: 75,
|
||||
jpegQuality: 80,
|
||||
};
|
||||
|
||||
/** Pick the smallest of N encoded buffers. */
|
||||
@@ -48,43 +47,34 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
|
||||
/**
|
||||
* Resize and recompress an image to fit within the specified max dimensions and file size.
|
||||
*
|
||||
* Defaults target Anthropic's internal 1568px downscale threshold and produce small
|
||||
* lossy JPEG output suitable for tool-call payloads (~100–500KB typical).
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Fast path — if input already fits dimensions AND is at <=25% of byte budget,
|
||||
* return as-is. Avoids re-encoding tiny icons/diagrams.
|
||||
* 2. Resize to maxWidth/maxHeight, encode both PNG and JPEG at default quality,
|
||||
* pick whichever is smaller. PNG wins for line art / few-color UI; JPEG wins
|
||||
* for photographic content.
|
||||
* 3. If still too large, JPEG-only quality ladder (PNG quality is a no-op).
|
||||
* 4. If still too large, progressively reduce dimensions and retry the JPEG ladder.
|
||||
* 5. Last resort: ship the smallest variant produced.
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* On any decode failure, returns the original bytes unchanged with wasResized=false.
|
||||
* Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
|
||||
* off the JS thread when the terminal (`.bytes()`) is awaited.
|
||||
*/
|
||||
export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const inputBuffer = Buffer.from(img.data, "base64");
|
||||
|
||||
try {
|
||||
const image = await PhotonImage.parse(inputBuffer);
|
||||
const { width: originalWidth, height: originalHeight, format } = await new Bun.Image(inputBuffer).metadata();
|
||||
const sourceMime = img.mimeType ?? `image/${format}`;
|
||||
|
||||
const originalWidth = image.width;
|
||||
const originalHeight = image.height;
|
||||
const format = img.mimeType?.split("/")[1] ?? "png";
|
||||
|
||||
// Check if already within all limits (dimensions AND size)
|
||||
const originalSize = inputBuffer.length;
|
||||
// Fast path: skip if already within dimensions AND well under budget.
|
||||
// Fast path: already within dimensions AND well under budget.
|
||||
// Threshold is 1/4 of budget — if already that compact, don't re-encode.
|
||||
// Avoids wasted work on tiny icons/diagrams while ensuring larger PNGs
|
||||
// still get JPEG-compressed.
|
||||
const originalSize = inputBuffer.length;
|
||||
const comfortableSize = opts.maxBytes / 4;
|
||||
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= comfortableSize) {
|
||||
return {
|
||||
buffer: inputBuffer,
|
||||
mimeType: img.mimeType ?? `image/${format}`,
|
||||
mimeType: sourceMime,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
width: originalWidth,
|
||||
@@ -117,14 +107,11 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
||||
height: number,
|
||||
quality: number,
|
||||
): Promise<{ buffer: Uint8Array; mimeType: string }> {
|
||||
const resized = await image.resize(width, height, SamplingFilter.Lanczos3);
|
||||
|
||||
const [pngBuffer, jpegBuffer, webpBuffer] = await Promise.all([
|
||||
resized.encode(ImageFormat.PNG, quality),
|
||||
resized.encode(ImageFormat.JPEG, quality),
|
||||
resized.encode(ImageFormat.WEBP, quality),
|
||||
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(),
|
||||
]);
|
||||
|
||||
return pickSmallest(
|
||||
{ buffer: pngBuffer, mimeType: "image/png" },
|
||||
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
|
||||
@@ -140,10 +127,9 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
||||
height: number,
|
||||
quality: number,
|
||||
): Promise<{ buffer: Uint8Array; mimeType: string }> {
|
||||
const resized = await image.resize(width, height, SamplingFilter.Lanczos3);
|
||||
const [jpegBuffer, webpBuffer] = await Promise.all([
|
||||
resized.encode(ImageFormat.JPEG, quality),
|
||||
resized.encode(ImageFormat.WEBP, quality),
|
||||
new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
|
||||
new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
|
||||
]);
|
||||
return pickSmallest(
|
||||
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
|
||||
@@ -159,7 +145,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
||||
let finalWidth = targetWidth;
|
||||
let finalHeight = targetHeight;
|
||||
|
||||
// First attempt: resize to target, try both PNG and JPEG, pick smaller
|
||||
// First attempt: resize to target, try PNG/JPEG/WebP, pick smallest
|
||||
best = await encodeSmallest(targetWidth, targetHeight, opts.jpegQuality);
|
||||
|
||||
if (best.buffer.length <= opts.maxBytes) {
|
||||
@@ -264,9 +250,11 @@ export function formatDimensionNote(result: ResizedImage): string | undefined {
|
||||
if (!result.wasResized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scale = result.originalWidth / result.width;
|
||||
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${
|
||||
result.height
|
||||
}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
|
||||
if (!result.originalWidth || !result.originalHeight || !result.width || !result.height) {
|
||||
return undefined;
|
||||
}
|
||||
if (result.width === result.originalWidth && result.height === result.originalHeight) {
|
||||
return undefined;
|
||||
}
|
||||
return `Image resized from ${result.originalWidth}x${result.originalHeight} to ${result.width}x${result.height}.`;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import { afterEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as imageConvert from "../src/utils/image-convert";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ensureSupportedImageInput } from "../src/utils/image-loading";
|
||||
|
||||
// 1x1 red PNG (69 bytes). Bun.Image sniffs format from bytes, so we can pass
|
||||
// this with a non-supported MIME type and the conversion path runs over the
|
||||
// real native pipeline.
|
||||
const RED_1X1_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
|
||||
|
||||
describe("ensureSupportedImageInput", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
test("passes supported mime types through unchanged", async () => {
|
||||
const input = { type: "image" as const, data: RED_1X1_PNG_BASE64, mimeType: "image/png" };
|
||||
const result = await ensureSupportedImageInput(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
test("converts unsupported image input to png", async () => {
|
||||
const convertToPngSpy = vi
|
||||
.spyOn(imageConvert, "convertToPng")
|
||||
.mockResolvedValue({ data: "pngdata", mimeType: "image/png" });
|
||||
const result = await ensureSupportedImageInput({
|
||||
type: "image",
|
||||
data: RED_1X1_PNG_BASE64,
|
||||
mimeType: "image/bmp",
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
// PNG re-encode of a 1x1 image must yield a valid (non-empty) PNG signature
|
||||
// (`89 50 4E 47`) when decoded from base64.
|
||||
const bytes = Buffer.from(result!.data, "base64");
|
||||
expect(bytes.length).toBeGreaterThan(0);
|
||||
expect(bytes[0]).toBe(0x89);
|
||||
expect(bytes[1]).toBe(0x50);
|
||||
expect(bytes[2]).toBe(0x4e);
|
||||
expect(bytes[3]).toBe(0x47);
|
||||
});
|
||||
|
||||
const result = await ensureSupportedImageInput({ type: "image", data: "bmpdata", mimeType: "image/bmp" });
|
||||
|
||||
expect(convertToPngSpy).toHaveBeenCalledWith("bmpdata", "image/bmp");
|
||||
expect(result).toEqual({ type: "image", data: "pngdata", mimeType: "image/png" });
|
||||
test("returns null when input bytes are not a decodable image", async () => {
|
||||
const result = await ensureSupportedImageInput({
|
||||
type: "image",
|
||||
data: Buffer.from("not an image").toString("base64"),
|
||||
mimeType: "image/bmp",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { ImageFormat, PhotonImage, SamplingFilter } from "@oh-my-pi/pi-natives";
|
||||
import { resizeImage } from "../../src/utils/image-resize";
|
||||
|
||||
// 1x1 red PNG (69 bytes) — used as a Photon seed to synthesize larger fixtures
|
||||
// 1x1 red PNG (69 bytes) — used as a Bun.Image seed to synthesize larger fixtures
|
||||
// without checking binary blobs into the repo.
|
||||
const RED_1X1_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC";
|
||||
|
||||
async function makeRedPng(width: number, height: number): Promise<string> {
|
||||
const seed = await PhotonImage.parse(new Uint8Array(Buffer.from(RED_1X1_PNG_BASE64, "base64")));
|
||||
const upscaled = await seed.resize(width, height, SamplingFilter.Nearest);
|
||||
const bytes = await upscaled.encode(ImageFormat.PNG, 100);
|
||||
return Buffer.from(bytes).toBase64();
|
||||
const seed = Buffer.from(RED_1X1_PNG_BASE64, "base64");
|
||||
const upscaled = await new Bun.Image(seed).resize(width, height, { filter: "nearest" }).png().bytes();
|
||||
return Buffer.from(upscaled).toBase64();
|
||||
}
|
||||
|
||||
describe("resizeImage defaults", () => {
|
||||
|
||||
Reference in New Issue
Block a user