diff --git a/.changeset/image-compression.md b/.changeset/image-compression.md new file mode 100644 index 000000000..abcfcc47c --- /dev/null +++ b/.changeset/image-compression.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Automatically compress oversized images before they reach the model. Whatever the source — pasted into the CLI, uploaded from the web/desktop client, sent over ACP, read via `ReadMediaFile`, or returned by an MCP tool — images are downsampled (longest edge ≤ 2000px) and re-encoded to fit a per-image byte budget, cutting vision-token cost and avoiding provider image-size errors. Screenshots stay lossless PNG and only degrade to JPEG when the byte budget cannot otherwise be met. Compression runs as an input-stage step at each ingestion point (while the content part is built), and guards against decompression bombs by skipping absurdly large pixel/byte payloads before decoding. Best-effort: if it fails for any reason the original image is sent unchanged. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e81ead095..266a73347 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -94,6 +94,7 @@ "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "commander": "^13.1.0", + "jimp": "^1.6.1", "pathe": "^2.0.3", "postject": "1.0.0-alpha.6", "semver": "^7.7.4", diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index db3717f83..735fb0e0e 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -1,4 +1,5 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; +import { compressImageForModel } from '@moonshot-ai/kimi-code-sdk'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import { parseImageMeta } from '#/utils/image/image-mime'; @@ -360,7 +361,19 @@ export class EditorKeyboardController { const meta = parseImageMeta(media.bytes); if (meta === null) return false; - const attachment = this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); + // Compress at ingestion — a pure data step while building the attachment, so + // the stored bytes, the inline thumbnail, the `[image #N (W×H)]` placeholder, + // and the submitted image all agree, and the agent core only ever sees an + // already-compressed image. Best effort: originals pass through on failure. + const compressed = await compressImageForModel(media.bytes, meta.mime); + const attachment = compressed.changed + ? this.imageStore.addImage( + compressed.data, + compressed.mimeType, + compressed.width, + compressed.height, + ) + : this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); this.host.state.ui.requestRender(); this.host.track('shortcut_paste', { kind: 'image' }); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts new file mode 100644 index 000000000..bbead65ec --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -0,0 +1,114 @@ +/** + * Clipboard image paste → attachment store, with ingestion-time compression. + * + * Tests pin: + * - an oversized pasted image is downsampled while building the attachment, + * so the stored bytes, the `[image #N (W×H)]` placeholder, and the eventual + * submitted image all agree on the compressed size + * - a within-budget paste is stored byte-for-byte (fast path) + */ + +import { Jimp } from 'jimp'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + EditorKeyboardController, + type EditorKeyboardHost, +} from '#/tui/controllers/editor-keyboard'; +import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; +import { parseImageMeta } from '#/utils/image/image-mime'; + +// vitest hoists vi.mock/vi.hoisted above the imports above, so the mock still +// applies to the editor-keyboard module that pulls in readClipboardMedia. +const { readClipboardMedia } = vi.hoisted(() => ({ readClipboardMedia: vi.fn() })); + +vi.mock('#/utils/clipboard/clipboard-image', async (importActual) => { + const actual = await importActual(); + return { ...actual, readClipboardMedia }; +}); + +interface PasteHarness { + readonly store: ImageAttachmentStore; + pasteImage(): Promise; +} + +function createPasteHarness(): PasteHarness { + const editor: Record unknown) | undefined> = {}; + const store = new ImageAttachmentStore(); + const host = { + state: { + editor, + activeDialog: null, + appState: { streamingPhase: 'idle', isCompacting: false }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: undefined, + btwPanelController: { closeOrCancel: vi.fn(() => false) }, + track: vi.fn(), + showError: vi.fn(), + openUndoSelector: vi.fn(), + cancelRunningShellCommand: vi.fn(), + } as unknown as EditorKeyboardHost; + + const controller = new EditorKeyboardController(host, store); + controller.install(); + + return { + store, + async pasteImage() { + const handler = editor['onPasteImage']; + if (handler === undefined) throw new Error('onPasteImage handler not installed'); + await (handler as () => Promise)(); + }, + }; +} + +async function solidPng(width: number, height: number): Promise { + return new Uint8Array( + await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'), + ); +} + +describe('clipboard image paste compression', () => { + beforeEach(() => { + readClipboardMedia.mockReset(); + }); + + it('downsamples an oversized pasted image before storing it', async () => { + const big = await solidPng(2600, 2600); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + expect(store.size()).toBe(1); + const att = store.get(1); + expect(att?.kind).toBe('image'); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + + // Stored metadata reflects the compressed size. + expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000); + expect(att.placeholder).toContain('2000×2000'); + + // The stored bytes decode to the compressed dimensions — the thumbnail and + // the submitted image both read from these bytes, so they cannot diverge. + const dims = parseImageMeta(att.bytes); + expect(dims).not.toBeNull(); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000); + }); + + it('stores a within-budget paste byte-for-byte', async () => { + const small = await solidPng(80, 80); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: small, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.width).toBe(80); + expect(att.height).toBe(80); + expect(att.bytes).toBe(small); // identity: no re-encode on the fast path + }); +}); diff --git a/flake.nix b/flake.nix index 106d7a73d..5a5e0469d 100644 --- a/flake.nix +++ b/flake.nix @@ -152,7 +152,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-oratz8x67ZEJGTiNy+s4XaKe0TtpRKh63aIqkV79vvM="; + hash = "sha256-mqyi0VuPZwESZcdU5E8F3XUG99OH636knBfb8y6TQpw="; }; nativeBuildInputs = [ diff --git a/packages/acp-adapter/package.json b/packages/acp-adapter/package.json index 2614905ac..affcb8cb2 100644 --- a/packages/acp-adapter/package.json +++ b/packages/acp-adapter/package.json @@ -44,5 +44,8 @@ "@moonshot-ai/agent-core": "workspace:^", "@moonshot-ai/kaos": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^" + }, + "devDependencies": { + "jimp": "^1.6.1" } } diff --git a/packages/acp-adapter/src/convert.ts b/packages/acp-adapter/src/convert.ts index 782134046..ed67f1800 100644 --- a/packages/acp-adapter/src/convert.ts +++ b/packages/acp-adapter/src/convert.ts @@ -1,6 +1,7 @@ import type { ContentBlock, ToolCallContent } from '@agentclientprotocol/sdk'; import { log, + compressBase64ForModel, type PromptPart, type ToolInputDisplay, type ToolResultEvent, @@ -71,6 +72,41 @@ export function acpBlocksToPromptParts( return out; } +/** + * Shrink oversized inline images in a prompt-part list — the ACP ingestion + * point's input-stage compression, mirroring the CLI's paste-time and the + * server's upload-time step. Best effort: a part that cannot be compressed is + * passed through unchanged. + */ +export async function compressPromptImageParts( + parts: readonly PromptPart[], +): Promise { + const out: PromptPart[] = []; + for (const part of parts) { + if (part.type === 'image_url') { + const parsed = parseImageDataUrl(part.imageUrl.url); + if (parsed !== null) { + const result = await compressBase64ForModel(parsed.base64, parsed.mimeType); + if (result.changed) { + out.push({ + type: 'image_url', + imageUrl: { ...part.imageUrl, url: `data:${result.mimeType};base64,${result.base64}` }, + }); + continue; + } + } + } + out.push(part); + } + return out; +} + +function parseImageDataUrl(url: string): { mimeType: string; base64: string } | null { + const match = /^data:([^;,]+);base64,(.*)$/s.exec(url); + if (match === null) return null; + return { mimeType: match[1]!, base64: match[2]! }; +} + /** * Minimum-viable XML-attribute escaping for prompt-embedded resource * wrappers. The output is consumed by an LLM, not parsed by a canonical diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index d317dd2d4..8338c8d89 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -19,6 +19,7 @@ import { type KimiErrorPayload, type KimiHarness, type McpServerInfo, + type PromptPart, type QuestionAnswers, type QuestionRequest, type Session, @@ -38,7 +39,7 @@ import { } from './builtin-commands'; import { buildSessionConfigOptions } from './config-options'; import { listModelsFromHarness } from './model-catalog'; -import { acpBlocksToPromptParts } from './convert'; +import { acpBlocksToPromptParts, compressPromptImageParts } from './convert'; import { acpToolCallId, assistantDeltaToSessionUpdate, @@ -147,6 +148,13 @@ export class AcpSession { */ private skillCommandMap: ReadonlyMap = new Map(); + // One token per in-flight `prompt()` that is still awaiting image compression + // (before any turn exists). A `session/cancel` in that window has no turn to + // abort, so it flips every token and each affected `prompt()` returns + // `cancelled` instead of launching. A set (not a single field) so concurrent + // prompts are all covered rather than only the most recent. + private readonly pendingPromptAborts = new Set<{ aborted: boolean }>(); + /** * The most recent command palette advertised to the ACP client. Used by * `/help` so the response matches the client's `available_commands_update` @@ -268,6 +276,11 @@ export class AcpSession { * acceptable. */ async cancel(): Promise { + // If any prompt is mid-compression (no turn yet), mark them aborted so they + // do not launch once compression finishes. + for (const pending of this.pendingPromptAborts) { + pending.aborted = true; + } await this.session.cancel(); } @@ -715,7 +728,20 @@ export class AcpSession { * sees a JSON-RPC error rather than a hung request. */ async prompt(blocks: readonly ContentBlock[]): Promise { - const parts = acpBlocksToPromptParts(blocks); + // Compression happens before any turn exists, so honor a `session/cancel` + // that arrives during it: flip the flag from cancel() and bail out here + // rather than launching a turn the client already asked to stop. + const pending = { aborted: false }; + this.pendingPromptAborts.add(pending); + let parts: readonly PromptPart[]; + try { + parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks)); + } finally { + this.pendingPromptAborts.delete(pending); + } + if (pending.aborted) { + return { stopReason: 'cancelled' }; + } const sessionId = this.id; const conn = this.conn; diff --git a/packages/acp-adapter/test/cancel.test.ts b/packages/acp-adapter/test/cancel.test.ts index 81f9bd4fc..e741db135 100644 --- a/packages/acp-adapter/test/cancel.test.ts +++ b/packages/acp-adapter/test/cancel.test.ts @@ -14,6 +14,7 @@ import { type WriteTextFileResponse, } from '@agentclientprotocol/sdk'; import { log, type KimiHarness, type Session } from '@moonshot-ai/kimi-code-sdk'; +import { Jimp } from 'jimp'; import { AcpServer } from '../src/server'; import { AUTHED_STATUS } from './_helpers/harness-stubs'; @@ -139,4 +140,81 @@ describe('AcpServer cancel', () => { expect.objectContaining({ sessionId: 'sess-erroring' }), ); }); + + it('returns cancelled without launching when cancel arrives during image compression', async () => { + let promptCalls = 0; + const fakeSession = { + id: 'sess-cancel-compress', + prompt: async () => { + promptCalls += 1; + return undefined; + }, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => fakeSession, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const { sessionId } = await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + // A solid 2600×2600 image is small in bytes but slow enough to compress + // that the cancel below reliably lands mid-compression, before any turn. + const data = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ).toString('base64'); + + const promptP = client.prompt({ + sessionId, + prompt: [{ type: 'image', data, mimeType: 'image/png' }], + }); + await client.cancel({ sessionId }); + const res = await promptP; + + expect(res.stopReason).toBe('cancelled'); + expect(promptCalls).toBe(0); // the turn was never launched + }); + + it('cancels every prompt compressing concurrently, not just the most recent', async () => { + let promptCalls = 0; + const fakeSession = { + id: 'sess-cancel-concurrent', + prompt: async () => { + promptCalls += 1; + return undefined; + }, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => fakeSession, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const { sessionId } = await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + const data = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ).toString('base64'); + const imageBlock = { type: 'image' as const, data, mimeType: 'image/png' }; + + // Two prompts compressing at once; a single cancel must cover both. + const p1 = client.prompt({ sessionId, prompt: [imageBlock] }); + const p2 = client.prompt({ sessionId, prompt: [imageBlock] }); + await client.cancel({ sessionId }); + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1.stopReason).toBe('cancelled'); + expect(r2.stopReason).toBe('cancelled'); + expect(promptCalls).toBe(0); + }); }); diff --git a/packages/acp-adapter/test/convert.test.ts b/packages/acp-adapter/test/convert.test.ts index 593dc7135..2b4e84a2e 100644 --- a/packages/acp-adapter/test/convert.test.ts +++ b/packages/acp-adapter/test/convert.test.ts @@ -1,10 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ContentBlock } from '@agentclientprotocol/sdk'; +import { Jimp } from 'jimp'; import { log, type ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; -import { acpBlocksToPromptParts, displayBlockToAcpContent } from '../src/convert'; +import { + acpBlocksToPromptParts, + compressPromptImageParts, + displayBlockToAcpContent, +} from '../src/convert'; const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); const imageBlock = (data: string, mimeType: string): ContentBlock => ({ @@ -320,3 +325,31 @@ describe('displayBlockToAcpContent — plan_review branch (Phase 13.2)', () => { expect(displayBlockToAcpContent(cmd)).toBeNull(); }); }); + +describe('compressPromptImageParts', () => { + async function pngBase64(width: number, height: number): Promise { + const buf = await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'); + return Buffer.from(buf).toString('base64'); + } + + it('downsamples an oversized inline image part', async () => { + const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(2600, 2600), 'image/png')]); + const compressed = await compressPromptImageParts(parts); + + const part = compressed[0]; + if (part?.type !== 'image_url') throw new Error('expected an image_url part'); + const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url); + expect(match).not.toBeNull(); + const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64')); + expect(Math.max(decoded.width, decoded.height)).toBeLessThanOrEqual(2000); + }); + + it('passes a within-budget image and text through unchanged', async () => { + const parts = acpBlocksToPromptParts([ + imageBlock(await pngBase64(32, 32), 'image/png'), + textBlock('hi'), + ]); + const compressed = await compressPromptImageParts(parts); + expect(compressed).toEqual(parts); + }); +}); diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index fc9a6efb3..93e53b731 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -69,6 +69,7 @@ "ajv-formats": "^3.0.1", "chokidar": "^4.0.3", "ignore": "^5.3.2", + "jimp": "^1.6.1", "js-yaml": "^4.1.1", "linkedom": "^0.18.12", "node-pty": "^1.1.0", diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 14dcec22a..8447a137f 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -44,6 +44,23 @@ export type { QuestionBackgroundTaskInfo, } from './agent/background'; export type { ToolServices } from './tools/support/services'; + +// Image compression — the input-stage helper each ingestion site (CLI paste, +// server upload resolution, ACP, ReadMediaFile, MCP) calls to shrink oversized +// images while constructing the content part. Re-exported from the package root +// so consumers (node-sdk, server) import it without a deep subpath. +export { + compressImageForModel, + compressBase64ForModel, + compressImageContentParts, + IMAGE_BYTE_BUDGET, + MAX_IMAGE_EDGE_PX, +} from './tools/support/image-compress'; +export type { + CompressImageOptions, + CompressImageResult, + CompressBase64Result, +} from './tools/support/image-compress'; export { SingleModelProvider } from './session/provider-manager'; export type { BearerTokenProvider, diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts index 464218cef..96cd1dcc5 100644 --- a/packages/agent-core/src/mcp/output.ts +++ b/packages/agent-core/src/mcp/output.ts @@ -21,6 +21,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; +import { compressImageContentParts } from '../tools/support/image-compress'; import type { MCPContentBlock, MCPToolResult } from './types'; // MCP servers can produce arbitrarily large outputs; cap what we feed back to @@ -130,10 +131,10 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu * `mcp__github__create_pr`) — embedded into the `` * wrap when the result is media-only, so the model can attribute binary parts. */ -export function mcpResultToExecutableOutput( +export async function mcpResultToExecutableOutput( result: MCPToolResult, qualifiedToolName: string, -): { output: string | ContentPart[]; isError: boolean; truncated?: true } { +): Promise<{ output: string | ContentPart[]; isError: boolean; truncated?: true }> { const converted: ContentPart[] = []; for (const block of result.content) { const part = convertMCPContentBlock(block); @@ -143,7 +144,11 @@ export function mcpResultToExecutableOutput( } const wrapped = wrapMediaOnly(converted, qualifiedToolName); - const limited = applyOutputLimits(wrapped); + // Shrink oversized images BEFORE the per-part byte cap, so a large but + // compressible screenshot is downsampled and kept rather than dropped to a + // text notice. Best effort: parts that cannot be compressed pass through. + const compressed = await compressImageContentParts(wrapped); + const limited = applyOutputLimits(compressed); const output = collapseSingleText(limited.parts); return limited.truncated ? { output, isError: result.isError, truncated: true } diff --git a/packages/agent-core/src/tools/builtin/file/read-media.ts b/packages/agent-core/src/tools/builtin/file/read-media.ts index f21886974..e4045c7ce 100644 --- a/packages/agent-core/src/tools/builtin/file/read-media.ts +++ b/packages/agent-core/src/tools/builtin/file/read-media.ts @@ -30,6 +30,7 @@ import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; import { renderPrompt } from '../../../utils/render-prompt'; import { resolvePathAccessPath } from '../../policies/path-access'; import { MEDIA_SNIFF_BYTES, detectFileType, sniffImageDimensions } from '../../support/file-type'; +import { compressImageForModel } from '../../support/image-compress'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '../../support/rule-match'; import type { WorkspaceConfig } from '../../support/workspace'; @@ -222,12 +223,17 @@ export class ReadMediaFileTool implements BuiltinTool { } const data = await this.kaos.readBytes(safePath); - const base64 = data.toString('base64'); let mediaPart: ContentPart; if (fileType.kind === 'image') { + // Shrink oversized images so a large screenshot neither wastes context + // tokens nor trips the provider's per-image byte ceiling. Best effort: + // on any failure compressImageForModel returns the original bytes, so + // the read still succeeds with the uncompressed image. + const compressed = await compressImageForModel(data, fileType.mimeType); + const base64 = Buffer.from(compressed.data).toString('base64'); mediaPart = { type: 'image_url', - imageUrl: { url: `data:${fileType.mimeType};base64,${base64}` }, + imageUrl: { url: `data:${compressed.mimeType};base64,${base64}` }, }; } else if (this.videoUploader !== undefined) { mediaPart = await this.videoUploader({ @@ -236,6 +242,7 @@ export class ReadMediaFileTool implements BuiltinTool { filename: safePath.split(/[\\/]/).at(-1), }); } else { + const base64 = data.toString('base64'); mediaPart = { type: 'video_url', videoUrl: { url: `data:${fileType.mimeType};base64,${base64}` }, @@ -246,6 +253,10 @@ export class ReadMediaFileTool implements BuiltinTool { const openText = `<${tag} path="${safePath}">`; const closeText = ``; + // The summary always reports the ORIGINAL pixel size and byte size: the + // model derives relative coordinates and scales them by the original + // dimensions, so it must see the pre-compression size even when the + // image_url above carries a downsampled copy. const dimensions = fileType.kind === 'image' ? sniffImageDimensions(data) : null; const systemText = buildSystemSummary({ diff --git a/packages/agent-core/src/tools/support/image-compress.ts b/packages/agent-core/src/tools/support/image-compress.ts new file mode 100644 index 000000000..1300c9dda --- /dev/null +++ b/packages/agent-core/src/tools/support/image-compress.ts @@ -0,0 +1,371 @@ +/** + * Shrink oversized images before they reach the model. + * + * A multimodal request carries each image as a base64 data URL; an unbounded + * screenshot or photo wastes context tokens and can blow past the provider's + * per-image byte ceiling. This module downsamples and re-encodes such images + * so they fit a pixel + byte budget, while leaving already-small images + * untouched — the common case is a fast, codec-free pass-through. + * + * Design notes: + * - Pure JS (jimp), imported lazily so the codec is only paid for when an + * image actually needs work; startup and the fast path stay cheap. + * - Best effort: any decode/encode failure returns the original bytes + * unchanged (`changed: false`), so a compression problem never blocks a + * prompt. Callers simply send the original instead. + * - Only PNG and JPEG are re-encoded. GIF is passed through to preserve + * animation; WebP is passed through because the default jimp build ships no + * WebP codec. Unknown formats are passed through. + */ + +import type { ContentPart } from '@moonshot-ai/kosong'; + +import { sniffImageDimensions } from './file-type'; + +/** Longest-edge ceiling (px). Larger images are scaled down to fit. */ +export const MAX_IMAGE_EDGE_PX = 2000; + +/** + * Raw-byte budget for a single image. base64 inflates bytes by ~4/3, so a + * 3.75 MB raw payload stays under a 5 MB encoded ceiling. Tune to the active + * provider's per-image limit. + */ +export const IMAGE_BYTE_BUDGET = 3.75 * 1024 * 1024; + +/** Progressively lower JPEG quality until the payload fits the byte budget. */ +const JPEG_QUALITY_STEPS = [80, 60, 40, 20] as const; + +/** Last-ditch longest edge when the budget cannot be met at MAX_IMAGE_EDGE_PX. */ +const FALLBACK_EDGE_PX = 1000; + +/** + * Pixel-count ceiling above which we skip compression entirely. A tiny-byte, + * huge-dimension image (e.g. a solid 30000×30000 PNG) would otherwise be fully + * decoded into a multi-gigabyte bitmap by Jimp before any resize — a + * decompression-bomb OOM vector, since the byte budget alone never catches it. + * The header sniff gives us the dimensions without decoding, so we gate on them + * first. Set well above any legitimate photo/screenshot/scan (~100 MP); larger + * images pass through uncompressed, exactly as they did before compression + * existed. + */ +const MAX_DECODE_PIXELS = 100_000_000; + +/** + * Raw-byte ceiling above which compression is skipped rather than decoded. The + * byte budget bounds the *output*, but the compressor still has to load the + * *input* first: a huge base64 payload (e.g. an oversized or invalid image from + * an MCP tool) would be `Buffer.from`-decoded — and possibly handed to Jimp — + * before any downstream cap (like the 10 MB MCP per-part limit) can drop it. + * This bounds that input allocation. Set well above legitimate + * screenshots/photos; larger images pass through uncompressed. + */ +const MAX_DECODE_BYTES = 64 * 1024 * 1024; + +/** Formats we can both decode and re-encode with the default jimp build. */ +const RECODABLE_MIME = new Set(['image/png', 'image/jpeg']); + +export interface CompressImageOptions { + /** Override the longest-edge ceiling (px). */ + readonly maxEdge?: number; + /** Override the raw-byte budget. */ + readonly byteBudget?: number; + /** Override the raw-byte ceiling above which compression is skipped. */ + readonly maxDecodeBytes?: number; +} + +export interface CompressImageResult { + /** Bytes to send: the re-encoded image, or the original when unchanged. */ + readonly data: Uint8Array; + /** MIME of `data`. May differ from the input (e.g. png → jpeg). */ + readonly mimeType: string; + /** Pixel width of `data`; falls back to the input size when unknown. */ + readonly width: number; + /** Pixel height of `data`; falls back to the input size when unknown. */ + readonly height: number; + /** True only when `data` differs from the input bytes. */ + readonly changed: boolean; + readonly originalByteLength: number; + readonly finalByteLength: number; +} + +/** + * Downsample/re-encode `bytes` to fit the pixel + byte budget. + * + * Never throws: on any failure (unsupported format, decode error, a result + * that would be larger than the input) the original bytes are returned with + * `changed: false`. + */ +export async function compressImageForModel( + bytes: Uint8Array, + mimeType: string, + options: CompressImageOptions = {}, +): Promise { + const maxEdge = options.maxEdge ?? MAX_IMAGE_EDGE_PX; + const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET; + const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES; + const normalizedMime = normalizeMime(mimeType); + const dims = sniffImageDimensions(bytes); + + const passthrough = (): CompressImageResult => ({ + data: bytes, + mimeType, + width: dims?.width ?? 0, + height: dims?.height ?? 0, + changed: false, + originalByteLength: bytes.length, + finalByteLength: bytes.length, + }); + + if (bytes.length === 0) return passthrough(); + // Only re-encode formats the codec handles; everything else passes through. + if (!RECODABLE_MIME.has(normalizedMime)) return passthrough(); + + // Fast path: already within both budgets — no codec load, no allocation. + const longestEdge = dims ? Math.max(dims.width, dims.height) : 0; + const withinBytes = bytes.length <= byteBudget; + const withinEdge = longestEdge > 0 && longestEdge <= maxEdge; + if (withinBytes && (withinEdge || longestEdge === 0)) return passthrough(); + + // Decompression-bomb guard: refuse to decode absurd pixel counts. The sniff + // above gave us the dimensions without decoding, so this costs nothing. + if (dims && dims.width * dims.height > MAX_DECODE_PIXELS) return passthrough(); + // Refuse to decode very large byte payloads (e.g. a huge or invalid image + // from an MCP tool) that would be loaded just to be dropped downstream. + if (bytes.length > maxDecodeBytes) return passthrough(); + + try { + const { Jimp } = await import('jimp'); + const image = await Jimp.fromBuffer(Buffer.from(bytes)); + const sourceIsPng = normalizedMime === 'image/png'; + + // Scale so the longest edge fits maxEdge (never enlarges). + fitWithinEdge(image, maxEdge); + + const encoded = await encodeWithinBudget(image, { + sourceIsPng, + byteBudget, + fallbackEdge: FALLBACK_EDGE_PX, + }); + + // Keep the result when it actually helps: fewer bytes, or fewer pixels + // (a smaller image costs fewer vision tokens even if the byte count is + // flat, as with near-solid graphics). Otherwise the re-encode bought us + // nothing — send the original. + const originalPixels = (dims?.width ?? 0) * (dims?.height ?? 0); + const finalPixels = encoded.width * encoded.height; + const shrankBytes = encoded.data.length < bytes.length; + const shrankPixels = originalPixels > 0 && finalPixels < originalPixels; + if (!shrankBytes && !shrankPixels) return passthrough(); + + return { + data: encoded.data, + mimeType: encoded.mimeType, + width: encoded.width, + height: encoded.height, + changed: true, + originalByteLength: bytes.length, + finalByteLength: encoded.data.length, + }; + } catch { + // Decode/encode failure — keep the original bytes. + return passthrough(); + } +} + +export interface CompressBase64Result { + readonly base64: string; + readonly mimeType: string; + readonly changed: boolean; + readonly originalByteLength: number; + readonly finalByteLength: number; +} + +/** + * Convenience wrapper for call sites that already hold base64 (MCP results, + * data URLs). Decodes, compresses, and re-encodes to base64. Best effort: + * returns the original base64 unchanged on any failure. + */ +export async function compressBase64ForModel( + base64: string, + mimeType: string, + options: CompressImageOptions = {}, +): Promise { + // Skip very large payloads before allocating: base64 decodes to ~3/4 its + // length, so a payload whose decoded size would exceed the cap is passed + // through without the Buffer.from allocation (and without touching Jimp). + const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES; + const approxBytes = Math.floor((base64.length * 3) / 4); + if (approxBytes > maxDecodeBytes) { + return { + base64, + mimeType, + changed: false, + originalByteLength: approxBytes, + finalByteLength: approxBytes, + }; + } + let bytes: Buffer; + try { + bytes = Buffer.from(base64, 'base64'); + } catch { + return { + base64, + mimeType, + changed: false, + originalByteLength: 0, + finalByteLength: 0, + }; + } + const result = await compressImageForModel(bytes, mimeType, options); + if (!result.changed) { + return { + base64, + mimeType, + changed: false, + originalByteLength: result.originalByteLength, + finalByteLength: result.finalByteLength, + }; + } + return { + base64: Buffer.from(result.data).toString('base64'), + mimeType: result.mimeType, + changed: true, + originalByteLength: result.originalByteLength, + finalByteLength: result.finalByteLength, + }; +} + +/** + * Compress any inline base64 image parts in a content-part list — the single + * helper used by the prompt-ingestion chokepoint (every client's images) and + * the MCP tool-result path. Image parts whose URL is not a `data:` URL (e.g. a + * remote http(s) image) are passed through, as are non-image parts. Best + * effort: a part that fails to compress is left unchanged. + */ +export async function compressImageContentParts( + parts: readonly ContentPart[], + options: CompressImageOptions = {}, +): Promise { + const out: ContentPart[] = []; + for (const part of parts) { + if (part.type === 'image_url') { + const parsed = parseImageDataUrl(part.imageUrl.url); + if (parsed !== null) { + const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, options); + if (result.changed) { + out.push({ + type: 'image_url', + imageUrl: { ...part.imageUrl, url: `data:${result.mimeType};base64,${result.base64}` }, + }); + continue; + } + } + } + out.push(part); + } + return out; +} + +function parseImageDataUrl(url: string): { mimeType: string; base64: string } | null { + const match = /^data:([^;,]+);base64,(.*)$/s.exec(url); + if (match === null) return null; + return { mimeType: match[1]!, base64: match[2]! }; +} + +// ── internals ──────────────────────────────────────────────────────── + +/** The concrete jimp image instance type, derived from the lazily-loaded module. */ +type JimpImage = Awaited>; + +interface EncodedImage { + readonly data: Buffer; + readonly mimeType: string; + readonly width: number; + readonly height: number; +} + +interface EncodeOptions { + readonly sourceIsPng: boolean; + readonly byteBudget: number; + readonly fallbackEdge: number; +} + +/** + * Encode `image` (already fitted to the edge ceiling) under the byte budget. + * + * Strategy — prefer the source format so a downscaled screenshot stays lossless + * PNG (preserving text and transparency), and only fall back to lossy JPEG when + * PNG cannot meet the byte budget: + * - PNG source: PNG at the fitted size → a smaller PNG rescale → JPEG ladder. + * - JPEG source: JPEG quality ladder → a smaller JPEG rescale. + * + * Always returns the smallest buffer it produced, even if no attempt met the + * budget — the caller still gates on whether it actually helped. + */ +async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promise { + const { sourceIsPng, byteBudget, fallbackEdge } = opts; + let smallest: EncodedImage | null = null; + + const consider = (data: Buffer, mimeType: string): EncodedImage => { + const candidate: EncodedImage = { data, mimeType, width: image.width, height: image.height }; + if (smallest === null || candidate.data.length < smallest.data.length) { + smallest = candidate; + } + return candidate; + }; + + if (sourceIsPng) { + // Lossless PNG first: best for screenshots/UI (sharp text) and keeps alpha. + const png = await image.getBuffer('image/png', { deflateLevel: 9 }); + if (png.length <= byteBudget) return consider(png, 'image/png'); + consider(png, 'image/png'); + + // Over budget: a smaller PNG before going lossy. + if (fitWithinEdge(image, fallbackEdge)) { + const smallerPng = await image.getBuffer('image/png', { deflateLevel: 9 }); + if (smallerPng.length <= byteBudget) return consider(smallerPng, 'image/png'); + consider(smallerPng, 'image/png'); + } + + // Last resort: lossy JPEG ladder (drops transparency) to meet the budget. + for (const quality of JPEG_QUALITY_STEPS) { + const jpeg = await image.getBuffer('image/jpeg', { quality }); + if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg'); + consider(jpeg, 'image/jpeg'); + } + return smallest!; + } + + // JPEG source: quality ladder, then a smaller rescale at the lowest quality. + for (const quality of JPEG_QUALITY_STEPS) { + const jpeg = await image.getBuffer('image/jpeg', { quality }); + if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg'); + consider(jpeg, 'image/jpeg'); + } + if (fitWithinEdge(image, fallbackEdge)) { + const jpeg = await image.getBuffer('image/jpeg', { quality: JPEG_QUALITY_STEPS.at(-1) }); + consider(jpeg, 'image/jpeg'); + } + + return smallest!; +} + +/** + * Scale `image` so its longest edge is at most `edge`, preserving aspect + * ratio. No-op (returns false) when the image already fits. + */ +function fitWithinEdge(image: JimpImage, edge: number): boolean { + const longest = Math.max(image.width, image.height); + if (longest <= edge) return false; + const factor = edge / longest; + image.resize({ + w: Math.max(1, Math.round(image.width * factor)), + h: Math.max(1, Math.round(image.height * factor)), + }); + return true; +} + +function normalizeMime(mimeType: string): string { + const lower = mimeType.trim().toLowerCase(); + return lower === 'image/jpg' ? 'image/jpeg' : lower; +} diff --git a/packages/agent-core/test/mcp/output.test.ts b/packages/agent-core/test/mcp/output.test.ts index 496ab45dc..e2e742fce 100644 --- a/packages/agent-core/test/mcp/output.test.ts +++ b/packages/agent-core/test/mcp/output.test.ts @@ -1,9 +1,11 @@ import { ContentBlockSchema } from '@modelcontextprotocol/sdk/types.js'; import type { ContentPart } from '@moonshot-ai/kosong'; +import { Jimp } from 'jimp'; import { describe, expect, test } from 'vitest'; import { convertMCPContentBlock, mcpResultToExecutableOutput } from '../../src/mcp/output'; import type { MCPContentBlock, MCPToolResult } from '../../src/mcp/types'; +import { sniffImageDimensions } from '../../src/tools/support/file-type'; const MCP_OUTPUT_TRUNCATED_TEXT = '\n\n[Output truncated: exceeded 100000 character limit. ' + @@ -205,29 +207,32 @@ describe('mcpResultToExecutableOutput', () => { return { content, isError }; } - test('collapses a single text part into a plain string', () => { - const out = mcpResultToExecutableOutput(result([{ type: 'text', text: 'hello' }]), 'mcp__s__t'); + test('collapses a single text part into a plain string', async () => { + const out = await mcpResultToExecutableOutput( + result([{ type: 'text', text: 'hello' }]), + 'mcp__s__t', + ); expect(out).toEqual({ output: 'hello', isError: false }); }); - test('propagates isError=true on the success-shape return', () => { - const out = mcpResultToExecutableOutput( + test('propagates isError=true on the success-shape return', async () => { + const out = await mcpResultToExecutableOutput( result([{ type: 'text', text: 'oops' }], true), 'mcp__s__t', ); expect(out).toEqual({ output: 'oops', isError: true }); }); - test('returns an empty string when the content array is empty', () => { - const out = mcpResultToExecutableOutput(result([]), 'mcp__s__t'); + test('returns an empty string when the content array is empty', async () => { + const out = await mcpResultToExecutableOutput(result([]), 'mcp__s__t'); // No parts survive; collapseSingleText has nothing to collapse so the // ContentPart[] branch wins. An empty array is the model-visible signal // that the tool returned no content. expect(out).toEqual({ output: [], isError: false }); }); - test('drops unconvertible blocks and keeps the rest', () => { - const out = mcpResultToExecutableOutput( + test('drops unconvertible blocks and keeps the rest', async () => { + const out = await mcpResultToExecutableOutput( result([ { type: 'text', text: 'kept' }, { type: 'fancy_new_type', text: 'dropped' }, @@ -237,8 +242,8 @@ describe('mcpResultToExecutableOutput', () => { expect(out).toEqual({ output: 'kept', isError: false }); }); - test('wraps media-only output in mcp_tool_result tags using the qualified name', () => { - const out = mcpResultToExecutableOutput( + test('wraps media-only output in mcp_tool_result tags using the qualified name', async () => { + const out = await mcpResultToExecutableOutput( result([{ type: 'image', data: 'AAA', mimeType: 'image/png' }]), 'mcp__github__create_pr', ); @@ -250,8 +255,8 @@ describe('mcpResultToExecutableOutput', () => { ]); }); - test('does NOT wrap when a non-empty text part accompanies the media', () => { - const out = mcpResultToExecutableOutput( + test('does NOT wrap when a non-empty text part accompanies the media', async () => { + const out = await mcpResultToExecutableOutput( result([ { type: 'text', text: 'caption' }, { type: 'image', data: 'AAA', mimeType: 'image/png' }, @@ -264,8 +269,8 @@ describe('mcpResultToExecutableOutput', () => { ]); }); - test('an empty-text companion still triggers the wrap', () => { - const out = mcpResultToExecutableOutput( + test('an empty-text companion still triggers the wrap', async () => { + const out = await mcpResultToExecutableOutput( result([ { type: 'text', text: '' }, { type: 'image', data: 'AAA', mimeType: 'image/png' }, @@ -277,8 +282,8 @@ describe('mcpResultToExecutableOutput', () => { expect(parts.at(-1)).toEqual({ type: 'text', text: '' }); }); - test('truncates oversized text and merges the notice into the surviving text part', () => { - const out = mcpResultToExecutableOutput( + test('truncates oversized text and merges the notice into the surviving text part', async () => { + const out = await mcpResultToExecutableOutput( result([{ type: 'text', text: 'x'.repeat(100_001) }]), 'mcp__s__t', ); @@ -288,10 +293,12 @@ describe('mcpResultToExecutableOutput', () => { expect(out.truncated).toBe(true); }); - test('drops oversized binary parts in favor of a per-part notice without touching the text budget', () => { - // 14 MiB base64 ≈ 10.5 MiB raw — just above the 10 MiB per-part cap. + test('drops oversized binary parts in favor of a per-part notice without touching the text budget', async () => { + // 14 MiB base64 ≈ 10.5 MiB raw — just above the 10 MiB per-part cap. The + // bytes are not a real image, so compression fails over and the drop path + // still applies. const huge = 'x'.repeat(14 * 1024 * 1024); - const out = mcpResultToExecutableOutput( + const out = await mcpResultToExecutableOutput( result([{ type: 'image', data: huge, mimeType: 'image/png' }]), 'mcp__s__big', ); @@ -308,8 +315,8 @@ describe('mcpResultToExecutableOutput', () => { expect(out.truncated).toBe(true); }); - test('binary part within the per-part cap survives intact alongside oversized text', () => { - const out = mcpResultToExecutableOutput( + test('binary part within the per-part cap survives intact alongside oversized text', async () => { + const out = await mcpResultToExecutableOutput( result([ { type: 'text', text: 'A'.repeat(100_000) }, { type: 'image', data: 'B'.repeat(500_000), mimeType: 'image/png' }, @@ -324,4 +331,28 @@ describe('mcpResultToExecutableOutput', () => { ]); expect(out).not.toHaveProperty('truncated'); }); + + test('downsamples an oversized real image instead of leaving it full-size', async () => { + const big = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ).toString('base64'); + + const out = await mcpResultToExecutableOutput( + result([{ type: 'image', data: big, mimeType: 'image/png' }]), + 'mcp__s__shot', + ); + + const parts = out.output as ContentPart[]; + const imagePart = parts.find((p) => p.type === 'image_url'); + expect(imagePart).toBeDefined(); + const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec( + (imagePart as { imageUrl: { url: string } }).imageUrl.url, + ); + expect(match).not.toBeNull(); + const dims = sniffImageDimensions(Buffer.from(match![2]!, 'base64')); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000); + // The image was compressed and kept, not dropped to a notice. + const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); + expect(joined).not.toContain('image_url dropped'); + }); }); diff --git a/packages/agent-core/test/tools/image-compress.test.ts b/packages/agent-core/test/tools/image-compress.test.ts new file mode 100644 index 000000000..00dfd7c93 --- /dev/null +++ b/packages/agent-core/test/tools/image-compress.test.ts @@ -0,0 +1,357 @@ +/** + * image-compress — downsample/re-encode oversized images for the model. + * + * Tests pin: + * - fast path: an image within both budgets passes through untouched + * (same byte reference, no re-encode) + * - dimension cap: an oversized image is scaled so its longest edge is + * exactly MAX_IMAGE_EDGE_PX, preserving aspect ratio + * - byte budget: an over-budget image walks the JPEG quality ladder and + * comes back as JPEG, strictly smaller than the input + * - alpha: a translucent PNG stays PNG when the budget allows, and only + * drops to JPEG as a last resort to meet a tiny budget + * - fallback: corrupt/empty bytes and non-recodable formats (GIF/WebP) + * return the original unchanged — never throws + * - invariant: `changed` implies the result is strictly smaller + * - base64 wrapper round-trips + * - performance: the fast path is codec-free; a large image compresses + * within a generous time bound + */ + +import { Jimp } from 'jimp'; +import { describe, expect, it } from 'vitest'; + +// eslint-disable-next-line import/no-unresolved +import { + compressBase64ForModel, + compressImageContentParts, + compressImageForModel, + IMAGE_BYTE_BUDGET, + MAX_IMAGE_EDGE_PX, +} from '../../src/tools/support/image-compress'; +// eslint-disable-next-line import/no-unresolved +import { sniffImageDimensions } from '../../src/tools/support/file-type'; + +// ── fixtures ───────────────────────────────────────────────────────── + +async function solidPng(width: number, height: number, color = 0x3366ccff): Promise { + const image = new Jimp({ width, height, color }); + return new Uint8Array(await image.getBuffer('image/png')); +} + +async function solidJpeg(width: number, height: number, color = 0x3366ccff): Promise { + const image = new Jimp({ width, height, color }); + return new Uint8Array(await image.getBuffer('image/jpeg', { quality: 90 })); +} + +async function translucentPng(width: number, height: number): Promise { + // Alpha 0x80 on every pixel → hasAlpha() is true. + const image = new Jimp({ width, height, color: 0x33_66_cc_80 }); + return new Uint8Array(await image.getBuffer('image/png')); +} + +/** High-entropy image whose PNG barely compresses — used to force the ladder. */ +async function noisePng(width: number, height: number, alpha = false): Promise { + const image = new Jimp({ width, height, color: 0x000000ff }); + const data = image.bitmap.data; + for (let i = 0; i < data.length; i += 4) { + // Deterministic pseudo-random bytes (no Math.random for stable fixtures). + // Distinct multipliers per channel keep entropy high so PNG barely shrinks. + data[i] = (i * 2_654_435_761) & 0xff; + data[i + 1] = (i * 40_503) & 0xff; + data[i + 2] = (i * 12_289) & 0xff; + data[i + 3] = alpha ? (i * 7 + 17) & 0xff : 0xff; + } + return new Uint8Array(await image.getBuffer('image/png')); +} + +async function decodeAlpha(bytes: Uint8Array): Promise { + const image = await Jimp.fromBuffer(Buffer.from(bytes)); + return image.hasAlpha(); +} + +// ── fast path ──────────────────────────────────────────────────────── + +describe('compressImageForModel — fast path', () => { + it('passes a within-budget image through untouched (same reference)', async () => { + const png = await solidPng(64, 64); + const result = await compressImageForModel(png, 'image/png'); + expect(result.changed).toBe(false); + expect(result.data).toBe(png); // identity: no copy, no re-encode + expect(result.mimeType).toBe('image/png'); + expect(result.width).toBe(64); + expect(result.height).toBe(64); + }); + + it('treats image/jpg as image/jpeg', async () => { + const jpeg = await solidJpeg(32, 32); + const result = await compressImageForModel(jpeg, 'image/jpg'); + expect(result.changed).toBe(false); + expect(result.data).toBe(jpeg); + }); +}); + +// ── dimension cap ──────────────────────────────────────────────────── + +describe('compressImageForModel — dimension cap', () => { + it('scales the longest edge down to MAX_IMAGE_EDGE_PX, preserving aspect', async () => { + const png = await solidPng(3000, 1500); + const result = await compressImageForModel(png, 'image/png'); + expect(result.changed).toBe(true); + expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX); + // 3000x1500 → 2000x1000 (aspect 2:1 preserved). + expect(result.width).toBe(2000); + expect(result.height).toBe(1000); + const dims = sniffImageDimensions(result.data); + expect(dims).toEqual({ width: 2000, height: 1000 }); + }); + + it('respects a custom maxEdge', async () => { + const png = await solidPng(1600, 800); + const result = await compressImageForModel(png, 'image/png', { maxEdge: 800 }); + expect(result.changed).toBe(true); + expect(result.width).toBe(800); + expect(result.height).toBe(400); + }); + + it('keeps a downscaled opaque PNG lossless (no needless JPEG conversion)', async () => { + // A screenshot-like opaque PNG that only needs downscaling must stay PNG so + // sharp text is not degraded by JPEG artifacts. + const png = await solidPng(3000, 1500); + const result = await compressImageForModel(png, 'image/png'); + expect(result.changed).toBe(true); + expect(result.mimeType).toBe('image/png'); + expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX); + }); +}); + +// ── byte budget ────────────────────────────────────────────────────── + +describe('compressImageForModel — byte budget', () => { + it('walks the JPEG ladder for an over-budget non-alpha image', async () => { + const png = await noisePng(900, 900); + const result = await compressImageForModel(png, 'image/png', { byteBudget: 8 * 1024 }); + expect(result.changed).toBe(true); + expect(result.mimeType).toBe('image/jpeg'); + expect(result.finalByteLength).toBeLessThan(result.originalByteLength); + }); + + it('keeps a translucent PNG as PNG when the budget allows', async () => { + const png = await translucentPng(2600, 2600); + const result = await compressImageForModel(png, 'image/png'); + expect(result.changed).toBe(true); + expect(result.mimeType).toBe('image/png'); + expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX); + expect(await decodeAlpha(result.data)).toBe(true); + }); + + it('drops alpha to JPEG only as a last resort under a tiny budget', async () => { + const png = await noisePng(800, 800, /* alpha */ true); + const result = await compressImageForModel(png, 'image/png', { byteBudget: 4 * 1024 }); + expect(result.changed).toBe(true); + expect(result.mimeType).toBe('image/jpeg'); + expect(result.finalByteLength).toBeLessThan(result.originalByteLength); + }); +}); + +// ── fallback / robustness ──────────────────────────────────────────── + +describe('compressImageForModel — fallback', () => { + it('returns the original on corrupt bytes (never throws)', async () => { + // Valid PNG signature followed by garbage — decode will fail. + const corrupt = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5]); + const result = await compressImageForModel(corrupt, 'image/png'); + expect(result.changed).toBe(false); + expect(result.data).toBe(corrupt); + }); + + it('passes empty buffers through', async () => { + const empty = new Uint8Array(0); + const result = await compressImageForModel(empty, 'image/png'); + expect(result.changed).toBe(false); + expect(result.data).toBe(empty); + }); + + it('passes GIF through (preserves animation)', async () => { + // Minimal GIF89a header — enough for the MIME guard to skip it. + const gif = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0]); + const result = await compressImageForModel(gif, 'image/gif'); + expect(result.changed).toBe(false); + expect(result.data).toBe(gif); + }); + + it('passes WebP through (no codec in the default build)', async () => { + const webp = new Uint8Array([ + 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, + ]); + const result = await compressImageForModel(webp, 'image/webp'); + expect(result.changed).toBe(false); + expect(result.data).toBe(webp); + }); + + it('skips compression for absurd pixel counts without decoding (bomb guard)', async () => { + // A PNG header advertising 30000×30000 (900 MP) with no pixel data. The + // dimension sniff reads the IHDR; the guard must pass through before Jimp + // is ever invoked, so this completes instantly with no multi-GB bitmap. + const header = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header, 0); + header.writeUInt32BE(13, 8); // IHDR chunk length + header.write('IHDR', 12, 'latin1'); + header.writeUInt32BE(30000, 16); + header.writeUInt32BE(30000, 20); + const bomb = new Uint8Array(header); + + const result = await compressImageForModel(bomb, 'image/png'); + expect(result.changed).toBe(false); + expect(result.data).toBe(bomb); // identity → Jimp was never called + }); + + it('skips compression for payloads over the byte cap without decoding', async () => { + // Over the edge (so not the fast path), but capped by maxDecodeBytes. + const png = await solidPng(3000, 100); + const result = await compressImageForModel(png, 'image/png', { maxDecodeBytes: 64 }); + expect(result.changed).toBe(false); + expect(result.data).toBe(png); // passthrough → Jimp was never called + }); +}); + +// ── invariants ─────────────────────────────────────────────────────── + +describe('compressImageForModel — invariants', () => { + it('changed always yields a within-cap, decodable payload', async () => { + const cases: Uint8Array[] = [ + await solidPng(3000, 1500), + await noisePng(900, 900), + await translucentPng(2600, 2600), + ]; + for (const bytes of cases) { + const result = await compressImageForModel(bytes, 'image/png'); + expect(result.finalByteLength).toBe(result.data.length); + if (result.changed) { + // A change is only kept when it helped: fewer bytes or fewer pixels. + const original = sniffImageDimensions(bytes)!; + const shrankBytes = result.finalByteLength < result.originalByteLength; + const shrankPixels = result.width * result.height < original.width * original.height; + expect(shrankBytes || shrankPixels).toBe(true); + // Dimensions never exceed the cap after a change. + expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(MAX_IMAGE_EDGE_PX); + // The result must decode. + expect(sniffImageDimensions(result.data)).not.toBeNull(); + } + } + }); +}); + +// ── base64 wrapper ─────────────────────────────────────────────────── + +describe('compressBase64ForModel', () => { + it('round-trips an over-sized image', async () => { + const png = await noisePng(700, 700); + const base64 = Buffer.from(png).toString('base64'); + const result = await compressBase64ForModel(base64, 'image/png', { byteBudget: 8 * 1024 }); + expect(result.changed).toBe(true); + expect(result.finalByteLength).toBeLessThan(result.originalByteLength); + // The re-encoded base64 still decodes to a valid image. + const dims = sniffImageDimensions(Buffer.from(result.base64, 'base64')); + expect(dims).not.toBeNull(); + }); + + it('returns the original base64 unchanged on the fast path', async () => { + const png = await solidPng(64, 64); + const base64 = Buffer.from(png).toString('base64'); + const result = await compressBase64ForModel(base64, 'image/png'); + expect(result.changed).toBe(false); + expect(result.base64).toBe(base64); + }); + + it('skips a base64 payload over the byte cap without decoding', async () => { + const png = await solidPng(3000, 100); // over edge, would otherwise compress + const base64 = Buffer.from(png).toString('base64'); + const result = await compressBase64ForModel(base64, 'image/png', { maxDecodeBytes: 64 }); + expect(result.changed).toBe(false); + expect(result.base64).toBe(base64); // unchanged → not decoded + }); +}); + +// ── performance ────────────────────────────────────────────────────── + +describe('compressImageForModel — performance', () => { + it('fast path is codec-free and quick across many calls', async () => { + const png = await solidPng(200, 200); + const start = performance.now(); + for (let i = 0; i < 100; i += 1) { + const result = await compressImageForModel(png, 'image/png'); + expect(result.data).toBe(png); // proves no decode/encode happened + } + const elapsed = performance.now() - start; + // 100 metadata-only checks should be well under 100ms. + expect(elapsed).toBeLessThan(100); + }); + + it('compresses a large image within a generous time bound', async () => { + const png = await solidPng(3000, 2000); + const start = performance.now(); + const result = await compressImageForModel(png, 'image/png'); + const elapsed = performance.now() - start; + expect(result.changed).toBe(true); + expect(elapsed).toBeLessThan(5000); + }); + + it('exposes a sane default budget', () => { + expect(IMAGE_BYTE_BUDGET).toBeGreaterThan(0); + expect(MAX_IMAGE_EDGE_PX).toBe(2000); + }); +}); + +// ── content-part helper ────────────────────────────────────────────── + +describe('compressImageContentParts', () => { + function dataUrl(mime: string, bytes: Uint8Array): string { + return `data:${mime};base64,${Buffer.from(bytes).toString('base64')}`; + } + + it('compresses an oversized inline image part, leaving other parts untouched', async () => { + const big = await solidPng(2600, 2600); + const parts = [ + { type: 'text' as const, text: 'look at this' }, + { type: 'image_url' as const, imageUrl: { url: dataUrl('image/png', big) } }, + ]; + const out = await compressImageContentParts(parts); + + expect(out[0]).toEqual({ type: 'text', text: 'look at this' }); + const imagePart = out[1]; + if (imagePart?.type !== 'image_url') throw new Error('expected image_url'); + const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(imagePart.imageUrl.url); + expect(match).not.toBeNull(); + const dims = sniffImageDimensions(Buffer.from(match![2]!, 'base64')); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(MAX_IMAGE_EDGE_PX); + }); + + it('preserves the part identity for a within-budget image (no change)', async () => { + const small = await solidPng(48, 48); + const url = dataUrl('image/png', small); + const parts = [{ type: 'image_url' as const, imageUrl: { url } }]; + const out = await compressImageContentParts(parts); + expect(out[0]).toEqual({ type: 'image_url', imageUrl: { url } }); + }); + + it('leaves remote (non-data) image URLs untouched', async () => { + const parts = [ + { type: 'image_url' as const, imageUrl: { url: 'https://example.com/pic.png' } }, + ]; + const out = await compressImageContentParts(parts); + expect(out[0]).toEqual({ type: 'image_url', imageUrl: { url: 'https://example.com/pic.png' } }); + }); + + it('keeps an image part id when rewriting the compressed url', async () => { + const big = await solidPng(2600, 2600); + const parts = [ + { type: 'image_url' as const, imageUrl: { url: dataUrl('image/png', big), id: 'att-1' } }, + ]; + const out = await compressImageContentParts(parts); + const imagePart = out[0]; + if (imagePart?.type !== 'image_url') throw new Error('expected image_url'); + expect(imagePart.imageUrl.id).toBe('att-1'); + expect(imagePart.imageUrl.url).not.toBe(dataUrl('image/png', big)); + }); +}); diff --git a/packages/agent-core/test/tools/read-media.test.ts b/packages/agent-core/test/tools/read-media.test.ts index 59c53fd9f..8adc5b9ab 100644 --- a/packages/agent-core/test/tools/read-media.test.ts +++ b/packages/agent-core/test/tools/read-media.test.ts @@ -4,6 +4,7 @@ import type { Kaos } from '@moonshot-ai/kaos'; import type { ContentPart, ModelCapability } from '@moonshot-ai/kosong'; +import { Jimp } from 'jimp'; import { describe, expect, it, vi } from 'vitest'; import { ToolAccesses } from '../../src/loop'; @@ -12,7 +13,7 @@ import { ReadMediaFileInputSchema, ReadMediaFileTool, } from '../../src/tools/builtin/file/read-media'; -import { MEDIA_SNIFF_BYTES } from '../../src/tools/support/file-type'; +import { MEDIA_SNIFF_BYTES, sniffImageDimensions } from '../../src/tools/support/file-type'; import { createFakeKaos, PERMISSIVE_WORKSPACE } from './fixtures/fake-kaos'; import { executeTool } from './fixtures/execute-tool'; @@ -651,4 +652,37 @@ describe('ReadMediaFileTool', () => { '"/workspace/fake.png" is not a supported image or video file. Use Read for text files, or Bash or an MCP tool for other binary formats.', ); }); + + it('downsamples an oversized image but reports original dimensions', async () => { + const big = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ); + expect(sniffImageDimensions(big)).toEqual({ width: 2600, height: 2600 }); + + const tool = makeReadMediaTool({ + stat: vi.fn().mockResolvedValue({ ...DEFAULT_STAT, stSize: big.length }), + readBytes: vi.fn().mockResolvedValue(big), + }); + + const result = await executeTool(tool, { + turnId: 't1', + toolCallId: 'c_big', + args: { path: '/workspace/big.png' }, + signal, + }); + + const parts = outputParts(result); + const url = (parts[2] as { imageUrl: { url: string } }).imageUrl.url; + const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(url); + expect(match).not.toBeNull(); + // The image actually sent to the model is downsampled to the edge cap. + const sentBytes = Buffer.from(match![2]!, 'base64'); + const sentDims = sniffImageDimensions(sentBytes); + expect(Math.max(sentDims!.width, sentDims!.height)).toBeLessThanOrEqual(2000); + + // The summary keeps the ORIGINAL size so coordinate mapping holds. + const systemText = (parts[0] as { text: string }).text; + expect(systemText).toContain('2600x2600'); + expect(systemText).toContain(`${String(big.length)} bytes`); + }); }); diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 8c428ecb6..21cd9bddd 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -66,6 +66,7 @@ "@moonshot-ai/kaos": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kosong": "workspace:^", - "@types/yazl": "^2.4.6" + "@types/yazl": "^2.4.6", + "jimp": "^1.6.1" } } diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 243535685..22eb0eb98 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -67,6 +67,21 @@ export { loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/agent-cor // outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY. export { installGlobalProxyDispatcher } from '@moonshot-ai/agent-core'; +// Image compression — ingestion sites (e.g. the CLI's clipboard paste, the ACP +// adapter) shrink oversized images while constructing the content part, before +// it enters a prompt. Best effort: returns the original on any failure. +export { + compressImageForModel, + compressBase64ForModel, + IMAGE_BYTE_BUDGET, + MAX_IMAGE_EDGE_PX, +} from '@moonshot-ai/agent-core'; +export type { + CompressImageOptions, + CompressImageResult, + CompressBase64Result, +} from '@moonshot-ai/agent-core'; + // Experimental feature flags — types only. Resolved values come from // `KimiHarness.getExperimentalFeatures()` over RPC, not from a re-exported runtime value. export type { diff --git a/packages/server/package.json b/packages/server/package.json index 7f0aa2088..9b42998e1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", - "@types/ws": "^8.18.0" + "@types/ws": "^8.18.0", + "jimp": "^1.6.1" } } diff --git a/packages/server/src/routes/prompts.ts b/packages/server/src/routes/prompts.ts index 4e10d8b06..517e806f7 100644 --- a/packages/server/src/routes/prompts.ts +++ b/packages/server/src/routes/prompts.ts @@ -12,7 +12,7 @@ import { promptSteerResultSchema, type PromptSubmission, } from '@moonshot-ai/protocol'; -import { IPromptService, AuthModelNotResolvedError, AuthProvisioningRequiredError, AuthTokenMissingError, AuthTokenUnauthorizedError, PromptAlreadyCompletedError, PromptNotFoundError, SessionBusyError, SessionNotFoundError, FileNotFoundError, IFileStore, type IInstantiationService, type GetResult } from '@moonshot-ai/agent-core'; +import { IPromptService, AuthModelNotResolvedError, AuthProvisioningRequiredError, AuthTokenMissingError, AuthTokenUnauthorizedError, PromptAlreadyCompletedError, PromptNotFoundError, SessionBusyError, SessionNotFoundError, FileNotFoundError, IFileStore, compressImageForModel, compressBase64ForModel, type IInstantiationService, type GetResult } from '@moonshot-ai/agent-core'; import { z } from 'zod'; @@ -254,6 +254,22 @@ async function resolvePromptMediaFiles( let changed = false; const content: PromptSubmission['content'] = []; for (const part of body.content) { + // Inline base64 image: compress the payload in place. This is the same + // input-stage step as the file path below, for REST clients that submit an + // image as `{ source: { kind: 'base64' } }` instead of uploading a file. + if (part.type === 'image' && part.source.kind === 'base64') { + const compressed = await compressBase64ForModel(part.source.data, part.source.media_type); + if (compressed.changed) { + content.push({ + type: 'image', + source: { kind: 'base64', media_type: compressed.mimeType, data: compressed.base64 }, + }); + changed = true; + } else { + content.push(part); + } + continue; + } if ((part.type !== 'image' && part.type !== 'video') || part.source.kind !== 'file') { content.push(part); continue; @@ -261,10 +277,21 @@ async function resolvePromptMediaFiles( const file = await store.get(part.source.file_id); assertMediaFile(file, part.type); const data = await readFile(file.blobPath); + // Compress the image while inlining it into the prompt (an input-stage data + // step, before the prompt reaches the agent core). The stored file keeps its + // original bytes; only the model-facing copy is shrunk. Best effort: a + // failure leaves the original bytes. Video is never re-encoded here. + let mediaType = file.meta.media_type; + let bytes: Uint8Array = data; + if (part.type === 'image') { + const compressed = await compressImageForModel(data, mediaType); + bytes = compressed.data; + mediaType = compressed.mimeType; + } const source = { kind: 'base64' as const, - media_type: file.meta.media_type, - data: data.toString('base64'), + media_type: mediaType, + data: Buffer.from(bytes).toString('base64'), }; content.push(part.type === 'video' ? { type: 'video', source } : { type: 'image', source }); changed = true; diff --git a/packages/server/test/prompt.e2e.test.ts b/packages/server/test/prompt.e2e.test.ts index c99fb9e2f..df576e51d 100644 --- a/packages/server/test/prompt.e2e.test.ts +++ b/packages/server/test/prompt.e2e.test.ts @@ -28,6 +28,7 @@ import { join } from 'node:path'; import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; +import { Jimp } from 'jimp'; import type { Event, PromptSubmission } from '@moonshot-ai/protocol'; import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core'; @@ -421,6 +422,111 @@ describe('POST /api/v1/sessions/{sid}/prompts — submit validation (W7.2 / Chai ]); }); + it('compresses an oversized uploaded image when resolving the prompt, leaving the stored file intact', async () => { + let submitted: PromptSubmission | undefined; + const r = await bootDaemon([ + [ + IPromptService, + createPromptServiceOverride({ + submit: async (_sid, body) => { + submitted = body; + return { + prompt_id: 'prompt_from_stub', + user_message_id: 'msg_from_stub', + status: 'running', + content: body.content, + created_at: '2026-06-09T00:00:00.000Z', + }; + }, + }), + ], + ]); + const sid = await createSession(r); + + const bigPng = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ); + const upload = buildMultipart({ + file: { fieldName: 'file', filename: 'big.png', contentType: 'image/png', data: bigPng }, + }); + const uploadRes = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/files', + payload: upload.body, + headers: { 'content-type': upload.contentType }, + }); + const uploadEnv = envelopeOf<{ id: string; media_type: string; size: number }>( + uploadRes.json(), + ); + expect(uploadEnv.code).toBe(0); + // The stored file keeps its ORIGINAL bytes — only the prompt copy is shrunk. + expect(uploadEnv.data?.size).toBe(bigPng.length); + + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${sid}/prompts`, + payload: { + content: [{ type: 'image', source: { kind: 'file', file_id: uploadEnv.data!.id } }], + }, + }); + expect(envelopeOf(res.json()).code).toBe(0); + + const part = submitted?.content[0]; + if (part?.type !== 'image' || part.source.kind !== 'base64') { + throw new Error('expected a resolved base64 image part'); + } + const sentBytes = Buffer.from(part.source.data, 'base64'); + const decoded = await Jimp.fromBuffer(sentBytes); + // The model-facing copy is downsampled to the edge cap. + expect(Math.max(decoded.width, decoded.height)).toBeLessThanOrEqual(2000); + }); + + it('compresses an inline base64 image submitted directly in the prompt', async () => { + let submitted: PromptSubmission | undefined; + const r = await bootDaemon([ + [ + IPromptService, + createPromptServiceOverride({ + submit: async (_sid, body) => { + submitted = body; + return { + prompt_id: 'prompt_from_stub', + user_message_id: 'msg_from_stub', + status: 'running', + content: body.content, + created_at: '2026-06-09T00:00:00.000Z', + }; + }, + }), + ], + ]); + const sid = await createSession(r); + + // Solid 2600×2600: over the edge cap but tiny in bytes, so it stays well + // under Fastify's inline-JSON limit yet still benefits from downscaling. + const base64 = Buffer.from( + await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'), + ).toString('base64'); + + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${sid}/prompts`, + payload: { + content: [ + { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: base64 } }, + ], + }, + }); + expect(envelopeOf(res.json()).code).toBe(0); + + const part = submitted?.content[0]; + if (part?.type !== 'image' || part.source.kind !== 'base64') { + throw new Error('expected a base64 image part'); + } + const decoded = await Jimp.fromBuffer(Buffer.from(part.source.data, 'base64')); + expect(Math.max(decoded.width, decoded.height)).toBeLessThanOrEqual(2000); + }); + it('returns 40407 when prompt image file_id is unknown', async () => { let submitted = false; const r = await bootDaemon([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 858021436..c31ef2eb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,7 +59,7 @@ importers: version: 2.13.1 tsdown: specifier: 0.22.0 - version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2)) + version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(vue-tsc@3.2.9(typescript@6.0.2)) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -117,6 +117,9 @@ importers: commander: specifier: ^13.1.0 version: 13.1.0 + jimp: + specifier: ^1.6.1 + version: 1.6.1 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -338,6 +341,10 @@ importers: '@moonshot-ai/kimi-code-sdk': specifier: workspace:^ version: link:../node-sdk + devDependencies: + jimp: + specifier: ^1.6.1 + version: 1.6.1 packages/agent-core: dependencies: @@ -374,6 +381,9 @@ importers: ignore: specifier: ^5.3.2 version: 5.3.2 + jimp: + specifier: ^1.6.1 + version: 1.6.1 js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -549,6 +559,9 @@ importers: '@types/yazl': specifier: ^2.4.6 version: 2.4.6 + jimp: + specifier: ^1.6.1 + version: 1.6.1 packages/oauth: dependencies: @@ -617,6 +630,9 @@ importers: '@types/ws': specifier: ^8.18.0 version: 8.18.1 + jimp: + specifier: ^1.6.1 + version: 1.6.1 packages/server-e2e: dependencies: @@ -889,6 +905,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} @@ -1625,6 +1644,118 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@jimp/core@1.6.1': + resolution: {integrity: sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==} + engines: {node: '>=18'} + + '@jimp/diff@1.6.1': + resolution: {integrity: sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==} + engines: {node: '>=18'} + + '@jimp/file-ops@1.6.1': + resolution: {integrity: sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==} + engines: {node: '>=18'} + + '@jimp/js-bmp@1.6.1': + resolution: {integrity: sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==} + engines: {node: '>=18'} + + '@jimp/js-gif@1.6.1': + resolution: {integrity: sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==} + engines: {node: '>=18'} + + '@jimp/js-jpeg@1.6.1': + resolution: {integrity: sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==} + engines: {node: '>=18'} + + '@jimp/js-png@1.6.1': + resolution: {integrity: sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==} + engines: {node: '>=18'} + + '@jimp/js-tiff@1.6.1': + resolution: {integrity: sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==} + engines: {node: '>=18'} + + '@jimp/plugin-blit@1.6.1': + resolution: {integrity: sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==} + engines: {node: '>=18'} + + '@jimp/plugin-blur@1.6.1': + resolution: {integrity: sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==} + engines: {node: '>=18'} + + '@jimp/plugin-circle@1.6.1': + resolution: {integrity: sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==} + engines: {node: '>=18'} + + '@jimp/plugin-color@1.6.1': + resolution: {integrity: sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==} + engines: {node: '>=18'} + + '@jimp/plugin-contain@1.6.1': + resolution: {integrity: sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==} + engines: {node: '>=18'} + + '@jimp/plugin-cover@1.6.1': + resolution: {integrity: sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==} + engines: {node: '>=18'} + + '@jimp/plugin-crop@1.6.1': + resolution: {integrity: sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==} + engines: {node: '>=18'} + + '@jimp/plugin-displace@1.6.1': + resolution: {integrity: sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==} + engines: {node: '>=18'} + + '@jimp/plugin-dither@1.6.1': + resolution: {integrity: sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==} + engines: {node: '>=18'} + + '@jimp/plugin-fisheye@1.6.1': + resolution: {integrity: sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==} + engines: {node: '>=18'} + + '@jimp/plugin-flip@1.6.1': + resolution: {integrity: sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==} + engines: {node: '>=18'} + + '@jimp/plugin-hash@1.6.1': + resolution: {integrity: sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==} + engines: {node: '>=18'} + + '@jimp/plugin-mask@1.6.1': + resolution: {integrity: sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==} + engines: {node: '>=18'} + + '@jimp/plugin-print@1.6.1': + resolution: {integrity: sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==} + engines: {node: '>=18'} + + '@jimp/plugin-quantize@1.6.1': + resolution: {integrity: sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==} + engines: {node: '>=18'} + + '@jimp/plugin-resize@1.6.1': + resolution: {integrity: sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==} + engines: {node: '>=18'} + + '@jimp/plugin-rotate@1.6.1': + resolution: {integrity: sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==} + engines: {node: '>=18'} + + '@jimp/plugin-threshold@1.6.1': + resolution: {integrity: sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==} + engines: {node: '>=18'} + + '@jimp/types@1.6.1': + resolution: {integrity: sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==} + engines: {node: '>=18'} + + '@jimp/utils@1.6.1': + resolution: {integrity: sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==} + engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2648,6 +2779,13 @@ packages: '@tanstack/virtual-core@3.14.0': resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@2.0.1': resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} @@ -2823,6 +2961,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@16.9.1': + resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -3159,6 +3300,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -3264,6 +3408,10 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + await-to-js@3.0.0: + resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} + engines: {node: '>=6.0.0'} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -3310,6 +3458,9 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + bmp-ts@1.0.9: + resolution: {integrity: sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -4162,6 +4313,9 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + exif-parser@0.1.12: + resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4260,6 +4414,10 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -4407,6 +4565,9 @@ packages: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} + gifwrap@0.10.1: + resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -4593,6 +4754,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + image-q@4.0.0: + resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} + import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} @@ -4849,6 +5013,10 @@ packages: engines: {node: '>=10'} hasBin: true + jimp@1.6.1: + resolution: {integrity: sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==} + engines: {node: '>=18'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -4863,6 +5031,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5373,6 +5544,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -5595,6 +5771,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + omggif@1.0.10: + resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -5712,6 +5891,18 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-bmfont-ascii@1.0.6: + resolution: {integrity: sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==} + + parse-bmfont-binary@1.0.6: + resolution: {integrity: sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==} + + parse-bmfont-xml@1.1.6: + resolution: {integrity: sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -5808,6 +5999,10 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pixelmatch@5.3.0: + resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -5820,6 +6015,14 @@ packages: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -6374,6 +6577,10 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} + simple-xml-to-json@1.2.7: + resolution: {integrity: sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==} + engines: {node: '>=20.12.2'} + sinon@22.0.0: resolution: {integrity: sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==} @@ -6545,6 +6752,10 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -6618,6 +6829,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyexec@1.1.1: resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} engines: {node: '>=18'} @@ -6660,6 +6874,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tokenx@1.3.0: resolution: {integrity: sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==} @@ -6792,6 +7010,10 @@ packages: uhyphen@0.2.0: resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + ulid@3.0.2: resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==} hasBin: true @@ -6880,6 +7102,9 @@ packages: utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + utif2@4.1.0: + resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -7213,6 +7438,17 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-parse-from-string@1.0.1: + resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==} + + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -7278,6 +7514,9 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -7605,6 +7844,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.2': {} + '@braidai/lang@1.1.2': {} '@braintree/sanitize-url@6.0.4': @@ -8293,6 +8534,229 @@ snapshots: dependencies: minipass: 7.1.3 + '@jimp/core@1.6.1': + dependencies: + '@jimp/file-ops': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + await-to-js: 3.0.0 + exif-parser: 0.1.12 + file-type: 21.3.4 + mime: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@jimp/diff@1.6.1': + dependencies: + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + pixelmatch: 5.3.0 + transitivePeerDependencies: + - supports-color + + '@jimp/file-ops@1.6.1': {} + + '@jimp/js-bmp@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + bmp-ts: 1.0.9 + transitivePeerDependencies: + - supports-color + + '@jimp/js-gif@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + gifwrap: 0.10.1 + omggif: 1.0.10 + transitivePeerDependencies: + - supports-color + + '@jimp/js-jpeg@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + jpeg-js: 0.4.4 + transitivePeerDependencies: + - supports-color + + '@jimp/js-png@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + pngjs: 7.0.0 + transitivePeerDependencies: + - supports-color + + '@jimp/js-tiff@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + utif2: 4.1.0 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-blit@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-blur@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/utils': 1.6.1 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-circle@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-color@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + tinycolor2: 1.6.0 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-contain@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-blit': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-cover@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-crop': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-crop@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-displace@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-dither@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + + '@jimp/plugin-fisheye@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-flip@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-hash@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/js-bmp': 1.6.1 + '@jimp/js-jpeg': 1.6.1 + '@jimp/js-png': 1.6.1 + '@jimp/js-tiff': 1.6.1 + '@jimp/plugin-color': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + any-base: 1.1.0 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-mask@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + zod: 3.25.76 + + '@jimp/plugin-print@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/js-jpeg': 1.6.1 + '@jimp/js-png': 1.6.1 + '@jimp/plugin-blit': 1.6.1 + '@jimp/types': 1.6.1 + parse-bmfont-ascii: 1.0.6 + parse-bmfont-binary: 1.0.6 + parse-bmfont-xml: 1.1.6 + simple-xml-to-json: 1.2.7 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-quantize@1.6.1': + dependencies: + image-q: 4.0.0 + zod: 3.25.76 + + '@jimp/plugin-resize@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/types': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-rotate@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-crop': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/plugin-threshold@1.6.1': + dependencies: + '@jimp/core': 1.6.1 + '@jimp/plugin-color': 1.6.1 + '@jimp/plugin-hash': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@jimp/types@1.6.1': + dependencies: + zod: 3.25.76 + + '@jimp/utils@1.6.1': + dependencies: + '@jimp/types': 1.6.1 + tinycolor2: 1.6.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8463,11 +8927,6 @@ snapshots: '@mozilla/readability@0.6.0': {} - '@napi-rs/wasm-runtime@1.1.4': - dependencies: - '@tybys/wasm-util': 0.10.1 - optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -8728,14 +9187,6 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.0.1': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': - dependencies: - '@napi-rs/wasm-runtime': 1.1.4 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) @@ -9095,6 +9546,15 @@ snapshots: '@tanstack/virtual-core@3.14.0': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tootallnate/once@2.0.1': {} '@tybys/wasm-util@0.10.1': @@ -9303,6 +9763,8 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@16.9.1': {} + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -9424,7 +9886,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: @@ -9695,6 +10157,8 @@ snapshots: ansis@4.2.0: {} + any-base@1.1.0: {} + any-promise@1.3.0: {} app-builder-bin@5.0.0-alpha.10: {} @@ -9852,6 +10316,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + await-to-js@3.0.0: {} + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -9890,6 +10356,8 @@ snapshots: bluebird@3.7.2: {} + bmp-ts@1.0.9: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -10922,6 +11390,8 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + exif-parser@0.1.12: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -11063,6 +11533,15 @@ snapshots: fflate@0.8.2: {} + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -11250,6 +11729,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gifwrap@0.10.1: + dependencies: + image-q: 4.0.0 + omggif: 1.0.10 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -11490,6 +11974,10 @@ snapshots: ignore@5.3.2: {} + image-q@4.0.0: + dependencies: + '@types/node': 16.9.1 + import-lazy@4.0.0: {} import-meta-resolve@4.2.0: {} @@ -11713,6 +12201,38 @@ snapshots: filelist: 1.0.6 picocolors: 1.1.1 + jimp@1.6.1: + dependencies: + '@jimp/core': 1.6.1 + '@jimp/diff': 1.6.1 + '@jimp/js-bmp': 1.6.1 + '@jimp/js-gif': 1.6.1 + '@jimp/js-jpeg': 1.6.1 + '@jimp/js-png': 1.6.1 + '@jimp/js-tiff': 1.6.1 + '@jimp/plugin-blit': 1.6.1 + '@jimp/plugin-blur': 1.6.1 + '@jimp/plugin-circle': 1.6.1 + '@jimp/plugin-color': 1.6.1 + '@jimp/plugin-contain': 1.6.1 + '@jimp/plugin-cover': 1.6.1 + '@jimp/plugin-crop': 1.6.1 + '@jimp/plugin-displace': 1.6.1 + '@jimp/plugin-dither': 1.6.1 + '@jimp/plugin-fisheye': 1.6.1 + '@jimp/plugin-flip': 1.6.1 + '@jimp/plugin-hash': 1.6.1 + '@jimp/plugin-mask': 1.6.1 + '@jimp/plugin-print': 1.6.1 + '@jimp/plugin-quantize': 1.6.1 + '@jimp/plugin-resize': 1.6.1 + '@jimp/plugin-rotate': 1.6.1 + '@jimp/plugin-threshold': 1.6.1 + '@jimp/types': 1.6.1 + '@jimp/utils': 1.6.1 + transitivePeerDependencies: + - supports-color + jiti@2.6.1: {} jju@1.4.0: {} @@ -11721,6 +12241,8 @@ snapshots: joycon@3.1.1: {} + jpeg-js@0.4.4: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -12355,6 +12877,8 @@ snapshots: mime@2.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -12559,6 +13083,8 @@ snapshots: obug@2.1.1: {} + omggif@1.0.10: {} + on-exit-leak-free@2.1.2: {} on-finished@2.4.1: @@ -12696,6 +13222,17 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: {} + + parse-bmfont-ascii@1.0.6: {} + + parse-bmfont-binary@1.0.6: {} + + parse-bmfont-xml@1.1.6: + dependencies: + xml-parse-from-string: 1.0.1 + xml2js: 0.5.0 + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -12790,6 +13327,10 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.2.0 + pixelmatch@5.3.0: + dependencies: + pngjs: 6.0.0 + pkce-challenge@5.0.1: {} pkg-pr-new@0.0.75: {} @@ -12800,6 +13341,10 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@6.0.0: {} + + pngjs@7.0.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -13123,31 +13668,6 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-rc.12: - dependencies: - '@oxc-project/types': 0.122.0 - '@rolldown/pluginutils': 1.0.0-rc.12 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-x64': 1.0.0-rc.12 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: '@oxc-project/types': 0.122.0 @@ -13515,6 +14035,8 @@ snapshots: dependencies: semver: 7.7.4 + simple-xml-to-json@1.2.7: {} + sinon@22.0.0: dependencies: '@sinonjs/commons': 3.0.1 @@ -13713,6 +14235,10 @@ snapshots: strip-json-comments@5.0.3: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + stylis@4.4.0: {} sumchecker@3.0.1: @@ -13795,6 +14321,8 @@ snapshots: tinybench@2.9.0: {} + tinycolor2@1.6.0: {} + tinyexec@1.1.1: {} tinyexec@1.1.2: {} @@ -13828,6 +14356,12 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tokenx@1.3.0: {} tough-cookie@5.1.2: @@ -13885,35 +14419,6 @@ snapshots: - oxc-resolver - vue-tsc - tsdown@0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2)): - dependencies: - ansis: 4.2.0 - cac: 7.0.0 - defu: 6.1.7 - empathic: 2.0.0 - hookable: 6.1.1 - import-without-cache: 0.4.0 - obug: 2.1.1 - picomatch: 4.0.4 - rolldown: 1.0.1 - rolldown-plugin-dts: 0.25.1(rolldown@1.0.1)(typescript@6.0.2)(vue-tsc@3.2.9(typescript@6.0.2)) - semver: 7.7.4 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 - tree-kill: 1.2.2 - unconfig-core: 7.5.0 - optionalDependencies: - '@arethetypeswrong/core': 0.18.2 - publint: 0.3.18 - tsx: 4.21.0 - typescript: 6.0.2 - unrun: 0.2.34 - transitivePeerDependencies: - - '@ts-macro/tsc' - - '@typescript/native-preview' - - oxc-resolver - - vue-tsc - tslib@2.8.1: {} tsx@4.21.0: @@ -13981,6 +14486,8 @@ snapshots: uhyphen@0.2.0: {} + uint8array-extras@1.5.0: {} + ulid@3.0.2: {} unbox-primitive@1.1.0: @@ -14056,14 +14563,6 @@ snapshots: unpipe@1.0.0: {} - unrun@0.2.34: - dependencies: - rolldown: 1.0.0-rc.12 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) @@ -14084,6 +14583,10 @@ snapshots: utf8-byte-length@1.0.5: {} + utif2@4.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} uuid@14.0.0: {} @@ -14434,6 +14937,15 @@ snapshots: xml-name-validator@5.0.0: optional: true + xml-parse-from-string@1.0.1: {} + + xml2js@0.5.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} xmlchars@2.2.0: @@ -14499,6 +15011,8 @@ snapshots: dependencies: zod: 4.3.6 + zod@3.25.76: {} + zod@4.3.6: {} zwitch@2.0.4: {}