refactor(coding-agent): migrated image utilities to shared pi-utils package
- Extracted image metadata detection and MIME type utilities to @oh-my-pi/pi-utils package for shared use across projects. - Consolidated image-input.ts and mime.ts modules into image-loading.ts with simplified API removing redundant metadata parameters. - Updated all import paths across coding-agent to use readImageMetadata from @oh-my-pi/pi-utils instead of local utilities. - Added peek-file utility module to @oh-my-pi/pi-utils with buffer pooling for efficient file header reading.
This commit is contained in:
@@ -1,6 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
|
||||
- Moved image metadata detection to @oh-my-pi/pi-utils package for shared use across projects
|
||||
- Simplified image loading API by removing redundant metadata parameters and consolidating image utilities
|
||||
- Updated imports to use readImageMetadata and parseImageMetadata from @oh-my-pi/pi-utils instead of local implementations
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed image-input.ts utility module; functionality consolidated into image-loading.ts
|
||||
- Removed mime.ts utility module; MIME detection moved to @oh-my-pi/pi-utils
|
||||
- Removed ImageMetadata interface and ReadImageMetadataOptions from local codebase
|
||||
|
||||
## [14.0.0] - 2026-04-08
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { getProjectDir, isEnoent, readImageMetadata } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "chalk";
|
||||
import { resolveReadPath } from "../tools/path-utils";
|
||||
import { formatBytes } from "../tools/render-utils";
|
||||
import { formatDimensionNote, resizeImage } from "../utils/image-resize";
|
||||
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
|
||||
|
||||
// Keep CLI startup responsive and avoid OOM when users pass huge files.
|
||||
// If a file exceeds these limits, we include it as a path-only <file/> block.
|
||||
@@ -42,7 +41,8 @@ export async function processFileArguments(fileArgs: string[], options?: Process
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
|
||||
const imageMetadata = await readImageMetadata(absolutePath);
|
||||
const mimeType = imageMetadata?.mimeType;
|
||||
const maxBytes = mimeType ? MAX_CLI_IMAGE_BYTES : MAX_CLI_TEXT_BYTES;
|
||||
if (stat.size > maxBytes) {
|
||||
console.error(
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../sessio
|
||||
import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
|
||||
import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
|
||||
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
||||
import { ensureSupportedImageInput } from "../../utils/image-input";
|
||||
import { ensureSupportedImageInput } from "../../utils/image-loading";
|
||||
import { resizeImage } from "../../utils/image-resize";
|
||||
import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getAntigravityHeaders, getEnvApiKey, StringEnum } from "@oh-my-pi/pi-ai";
|
||||
import { $env, isEnoent, prompt, ptree, readSseJson, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import {
|
||||
$env,
|
||||
isEnoent,
|
||||
parseImageMetadata,
|
||||
prompt,
|
||||
ptree,
|
||||
readSseJson,
|
||||
Snowflake,
|
||||
untilAborted,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import type { CustomTool } from "../extensibility/custom-tools/types";
|
||||
import geminiImageDescription from "../prompts/tools/gemini-image.md" with { type: "text" };
|
||||
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
|
||||
import { resolveReadPath } from "./path-utils";
|
||||
|
||||
const DEFAULT_MODEL = "gemini-3-pro-image-preview";
|
||||
@@ -416,7 +424,8 @@ async function loadImageFromPath(imagePath: string, cwd: string): Promise<Inline
|
||||
throw new Error(`Image file too large: ${imagePath}`);
|
||||
}
|
||||
|
||||
const mimeType = await detectSupportedImageMimeTypeFromFile(resolved);
|
||||
const metadata = parseImageMetadata(buffer);
|
||||
const mimeType = metadata?.mimeType;
|
||||
if (!mimeType) {
|
||||
throw new Error(`Unsupported image type: ${imagePath}`);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type LoadedImageInput,
|
||||
loadImageInput,
|
||||
MAX_IMAGE_INPUT_BYTES,
|
||||
} from "../utils/image-input";
|
||||
} from "../utils/image-loading";
|
||||
import type { ToolSession } from "./index";
|
||||
import { ToolError } from "./tool-errors";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
||||
import { glob } from "@oh-my-pi/pi-natives";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { getRemoteDir, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { computeLineHash } from "../edit/line-hash";
|
||||
import {
|
||||
@@ -34,14 +34,8 @@ import { renderCodeCell, renderStatusLine } from "../tui";
|
||||
import { CachedOutputBlock } from "../tui/output-block";
|
||||
import { resolveEditMode } from "../utils/edit-mode";
|
||||
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
||||
import {
|
||||
ImageInputTooLargeError,
|
||||
loadImageInput,
|
||||
MAX_IMAGE_INPUT_BYTES,
|
||||
readImageMetadata,
|
||||
} from "../utils/image-input";
|
||||
import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
|
||||
import { convertFileWithMarkit } from "../utils/markit";
|
||||
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
|
||||
import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader";
|
||||
import {
|
||||
executeReadUrl,
|
||||
@@ -812,7 +806,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
return dirResult;
|
||||
}
|
||||
|
||||
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
|
||||
const imageMetadata = await readImageMetadata(absolutePath);
|
||||
const mimeType = imageMetadata?.mimeType;
|
||||
const ext = path.extname(absolutePath).toLowerCase();
|
||||
const hasEditTool = this.session.hasEditTool ?? true;
|
||||
const language = getLanguageFromPath(absolutePath);
|
||||
@@ -872,14 +867,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
|
||||
if (mimeType) {
|
||||
if (this.#inspectImageEnabled) {
|
||||
const metadata = await readImageMetadata({
|
||||
path: readPath,
|
||||
cwd: this.session.cwd,
|
||||
resolvedPath: absolutePath,
|
||||
detectedMimeType: mimeType,
|
||||
});
|
||||
const metadata = imageMetadata;
|
||||
const outputMime = metadata?.mimeType ?? mimeType;
|
||||
const outputBytes = metadata?.bytes ?? fileSize;
|
||||
const outputBytes = fileSize;
|
||||
const metadataLines = [
|
||||
"Image metadata:",
|
||||
`- MIME: ${outputMime}`,
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import { glob } from "@oh-my-pi/pi-natives";
|
||||
import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
|
||||
import { formatHashLines } from "../edit/line-hash";
|
||||
import type { FileMentionMessage } from "../session/messages";
|
||||
import {
|
||||
@@ -18,10 +19,8 @@ import {
|
||||
truncateHeadBytes,
|
||||
} from "../session/streaming-output";
|
||||
import { resolveReadPath } from "../tools/path-utils";
|
||||
import { formatAge, formatBytes } from "../tools/render-utils";
|
||||
import { fuzzyMatch } from "./fuzzy";
|
||||
import { formatDimensionNote, resizeImage } from "./image-resize";
|
||||
import { detectSupportedImageMimeTypeFromFile } from "./mime";
|
||||
|
||||
/** Regex to match @filepath patterns in text */
|
||||
const FILE_MENTION_REGEX = /@([^\s@]+)/g;
|
||||
@@ -304,7 +303,8 @@ export async function generateFileMentionMessages(
|
||||
continue;
|
||||
}
|
||||
|
||||
const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
|
||||
const imageMetadata = await readImageMetadata(absolutePath);
|
||||
const mimeType = imageMetadata?.mimeType;
|
||||
if (mimeType) {
|
||||
if (stat.size > MAX_AUTO_READ_IMAGE_BYTES) {
|
||||
files.push({
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { formatBytes } from "@oh-my-pi/pi-utils";
|
||||
import { resolveReadPath } from "../tools/path-utils";
|
||||
import { convertToPng } from "./image-convert";
|
||||
import { formatDimensionNote, resizeImage } from "./image-resize";
|
||||
import { detectSupportedImageMimeTypeFromFile } from "./mime";
|
||||
|
||||
export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
|
||||
export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
export interface ImageMetadata {
|
||||
mimeType: string;
|
||||
bytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
channels?: number;
|
||||
hasAlpha?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadedImageInput {
|
||||
resolvedPath: string;
|
||||
mimeType: string;
|
||||
data: string;
|
||||
textNote: string;
|
||||
dimensionNote?: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
|
||||
if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
|
||||
return image;
|
||||
}
|
||||
const converted = await convertToPng(image.data, image.mimeType);
|
||||
return converted ? { type: "image", data: converted.data, mimeType: converted.mimeType } : null;
|
||||
}
|
||||
|
||||
export interface ReadImageMetadataOptions {
|
||||
path: string;
|
||||
cwd: string;
|
||||
resolvedPath?: string;
|
||||
detectedMimeType?: string;
|
||||
}
|
||||
|
||||
export interface LoadImageInputOptions extends ReadImageMetadataOptions {
|
||||
autoResize: boolean;
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export class ImageInputTooLargeError extends Error {
|
||||
readonly bytes: number;
|
||||
readonly maxBytes: number;
|
||||
|
||||
constructor(bytes: number, maxBytes: number) {
|
||||
super(`Image file too large: ${formatBytes(bytes)} exceeds ${formatBytes(maxBytes)} limit.`);
|
||||
this.name = "ImageInputTooLargeError";
|
||||
this.bytes = bytes;
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedImageHeaderMetadata {
|
||||
width?: number;
|
||||
height?: number;
|
||||
channels?: number;
|
||||
hasAlpha?: boolean;
|
||||
}
|
||||
|
||||
function parsePngMetadata(header: Buffer): ParsedImageHeaderMetadata {
|
||||
if (header.length < 26) return {};
|
||||
if (
|
||||
header[0] !== 0x89 ||
|
||||
header[1] !== 0x50 ||
|
||||
header[2] !== 0x4e ||
|
||||
header[3] !== 0x47 ||
|
||||
header[4] !== 0x0d ||
|
||||
header[5] !== 0x0a ||
|
||||
header[6] !== 0x1a ||
|
||||
header[7] !== 0x0a
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
if (header.slice(12, 16).toString("ascii") !== "IHDR") return {};
|
||||
const width = header.readUInt32BE(16);
|
||||
const height = header.readUInt32BE(20);
|
||||
const colorType = header[25];
|
||||
if (colorType === 0) return { width, height, channels: 1, hasAlpha: false };
|
||||
if (colorType === 2) return { width, height, channels: 3, hasAlpha: false };
|
||||
if (colorType === 3) return { width, height, channels: 3 };
|
||||
if (colorType === 4) return { width, height, channels: 2, hasAlpha: true };
|
||||
if (colorType === 6) return { width, height, channels: 4, hasAlpha: true };
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function parseJpegMetadata(header: Buffer): ParsedImageHeaderMetadata {
|
||||
if (header.length < 4) return {};
|
||||
if (header[0] !== 0xff || header[1] !== 0xd8) return {};
|
||||
|
||||
let offset = 2;
|
||||
while (offset + 9 < header.length) {
|
||||
if (header[offset] !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let markerOffset = offset + 1;
|
||||
while (markerOffset < header.length && header[markerOffset] === 0xff) {
|
||||
markerOffset += 1;
|
||||
}
|
||||
if (markerOffset >= header.length) break;
|
||||
|
||||
const marker = header[markerOffset];
|
||||
const segmentOffset = markerOffset + 1;
|
||||
|
||||
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01) {
|
||||
offset = segmentOffset;
|
||||
continue;
|
||||
}
|
||||
if (marker >= 0xd0 && marker <= 0xd7) {
|
||||
offset = segmentOffset;
|
||||
continue;
|
||||
}
|
||||
if (segmentOffset + 1 >= header.length) break;
|
||||
|
||||
const segmentLength = header.readUInt16BE(segmentOffset);
|
||||
if (segmentLength < 2) break;
|
||||
|
||||
const isStartOfFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
||||
if (isStartOfFrame) {
|
||||
if (segmentOffset + 7 >= header.length) break;
|
||||
const height = header.readUInt16BE(segmentOffset + 3);
|
||||
const width = header.readUInt16BE(segmentOffset + 5);
|
||||
const channels = header[segmentOffset + 7];
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
channels: Number.isFinite(channels) ? channels : undefined,
|
||||
hasAlpha: false,
|
||||
};
|
||||
}
|
||||
|
||||
offset = segmentOffset + segmentLength;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function parseGifMetadata(header: Buffer): ParsedImageHeaderMetadata {
|
||||
if (header.length < 10) return {};
|
||||
const signature = header.slice(0, 6).toString("ascii");
|
||||
if (signature !== "GIF87a" && signature !== "GIF89a") return {};
|
||||
return {
|
||||
width: header.readUInt16LE(6),
|
||||
height: header.readUInt16LE(8),
|
||||
channels: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWebpMetadata(header: Buffer): ParsedImageHeaderMetadata {
|
||||
if (header.length < 30) return {};
|
||||
if (header.slice(0, 4).toString("ascii") !== "RIFF") return {};
|
||||
if (header.slice(8, 12).toString("ascii") !== "WEBP") return {};
|
||||
|
||||
const chunkType = header.slice(12, 16).toString("ascii");
|
||||
if (chunkType === "VP8X") {
|
||||
const hasAlpha = (header[20] & 0x10) !== 0;
|
||||
const width = (header[24] | (header[25] << 8) | (header[26] << 16)) + 1;
|
||||
const height = (header[27] | (header[28] << 8) | (header[29] << 16)) + 1;
|
||||
return { width, height, channels: hasAlpha ? 4 : 3, hasAlpha };
|
||||
}
|
||||
if (chunkType === "VP8L") {
|
||||
if (header.length < 25) return {};
|
||||
const bits = header.readUInt32LE(21);
|
||||
const width = (bits & 0x3fff) + 1;
|
||||
const height = ((bits >> 14) & 0x3fff) + 1;
|
||||
const hasAlpha = ((bits >> 28) & 0x1) === 1;
|
||||
return { width, height, channels: hasAlpha ? 4 : 3, hasAlpha };
|
||||
}
|
||||
if (chunkType === "VP8 ") {
|
||||
const width = header.readUInt16LE(26) & 0x3fff;
|
||||
const height = header.readUInt16LE(28) & 0x3fff;
|
||||
return { width, height, channels: 3, hasAlpha: false };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function parseImageHeaderMetadata(header: Buffer, mimeType: string): ParsedImageHeaderMetadata {
|
||||
if (mimeType === "image/png") return parsePngMetadata(header);
|
||||
if (mimeType === "image/jpeg") return parseJpegMetadata(header);
|
||||
if (mimeType === "image/gif") return parseGifMetadata(header);
|
||||
if (mimeType === "image/webp") return parseWebpMetadata(header);
|
||||
return {};
|
||||
}
|
||||
|
||||
async function readHeader(filePath: string, maxBytes: number): Promise<Buffer> {
|
||||
if (maxBytes <= 0) return Buffer.alloc(0);
|
||||
const fileHandle = await fs.open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(maxBytes);
|
||||
const { bytesRead } = await fileHandle.read(buffer, 0, maxBytes, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readImageMetadata(options: ReadImageMetadataOptions): Promise<ImageMetadata | null> {
|
||||
const resolvedPath = options.resolvedPath ?? resolveReadPath(options.path, options.cwd);
|
||||
const mimeType = options.detectedMimeType ?? (await detectSupportedImageMimeTypeFromFile(resolvedPath));
|
||||
if (!mimeType) return null;
|
||||
|
||||
const stats = await Bun.file(resolvedPath).stat();
|
||||
const bytes = stats.size;
|
||||
const headerBytes = Math.max(0, Math.min(bytes, MAX_IMAGE_METADATA_HEADER_BYTES));
|
||||
const header = await readHeader(resolvedPath, headerBytes);
|
||||
const parsed = parseImageHeaderMetadata(header, mimeType);
|
||||
|
||||
return {
|
||||
mimeType,
|
||||
bytes,
|
||||
width: parsed.width,
|
||||
height: parsed.height,
|
||||
channels: parsed.channels,
|
||||
hasAlpha: parsed.hasAlpha,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null> {
|
||||
const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES;
|
||||
const resolvedPath = options.resolvedPath ?? resolveReadPath(options.path, options.cwd);
|
||||
const mimeType = options.detectedMimeType ?? (await detectSupportedImageMimeTypeFromFile(resolvedPath));
|
||||
if (!mimeType) return null;
|
||||
|
||||
const stat = await Bun.file(resolvedPath).stat();
|
||||
if (stat.size > maxBytes) {
|
||||
throw new ImageInputTooLargeError(stat.size, maxBytes);
|
||||
}
|
||||
|
||||
const inputBuffer = await fs.readFile(resolvedPath);
|
||||
if (inputBuffer.byteLength > maxBytes) {
|
||||
throw new ImageInputTooLargeError(inputBuffer.byteLength, maxBytes);
|
||||
}
|
||||
|
||||
let outputData = Buffer.from(inputBuffer).toBase64();
|
||||
let outputMimeType = mimeType;
|
||||
let outputBytes = inputBuffer.byteLength;
|
||||
let dimensionNote: string | undefined;
|
||||
|
||||
if (options.autoResize) {
|
||||
try {
|
||||
const resized = await resizeImage({ type: "image", data: outputData, mimeType });
|
||||
outputData = resized.data;
|
||||
outputMimeType = resized.mimeType;
|
||||
outputBytes = resized.buffer.byteLength;
|
||||
dimensionNote = formatDimensionNote(resized);
|
||||
} catch {
|
||||
// keep original image when resize fails
|
||||
}
|
||||
}
|
||||
|
||||
let textNote = `Read image file [${outputMimeType}]`;
|
||||
if (dimensionNote) {
|
||||
textNote += `\n${dimensionNote}`;
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedPath,
|
||||
mimeType: outputMimeType,
|
||||
data: outputData,
|
||||
textNote,
|
||||
dimensionNote,
|
||||
bytes: outputBytes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@oh-my-pi/pi-utils";
|
||||
import { resolveReadPath } from "../tools/path-utils";
|
||||
import { convertToPng } from "./image-convert";
|
||||
import { formatDimensionNote, resizeImage } from "./image-resize";
|
||||
|
||||
export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
|
||||
export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES;
|
||||
|
||||
export interface LoadImageInputOptions {
|
||||
path: string;
|
||||
cwd: string;
|
||||
autoResize: boolean;
|
||||
maxBytes?: number;
|
||||
resolvedPath?: string;
|
||||
detectedMimeType?: string;
|
||||
}
|
||||
|
||||
export interface LoadedImageInput {
|
||||
resolvedPath: string;
|
||||
mimeType: string;
|
||||
data: string;
|
||||
textNote: string;
|
||||
dimensionNote?: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export class ImageInputTooLargeError extends Error {
|
||||
readonly bytes: number;
|
||||
readonly maxBytes: number;
|
||||
|
||||
constructor(bytes: number, maxBytes: number) {
|
||||
super(`Image file too large: ${formatBytes(bytes)} exceeds ${formatBytes(maxBytes)} limit.`);
|
||||
this.name = "ImageInputTooLargeError";
|
||||
this.bytes = bytes;
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
|
||||
if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
|
||||
return image;
|
||||
}
|
||||
const converted = await convertToPng(image.data, image.mimeType);
|
||||
return converted ? { type: "image", data: converted.data, mimeType: converted.mimeType } : null;
|
||||
}
|
||||
|
||||
export async function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null> {
|
||||
const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES;
|
||||
const resolvedPath = options.resolvedPath ?? resolveReadPath(options.path, options.cwd);
|
||||
const metadata = options.detectedMimeType
|
||||
? { mimeType: options.detectedMimeType }
|
||||
: await readImageMetadata(resolvedPath);
|
||||
const mimeType = metadata?.mimeType;
|
||||
if (!mimeType) return null;
|
||||
|
||||
const stat = await Bun.file(resolvedPath).stat();
|
||||
if (stat.size > maxBytes) {
|
||||
throw new ImageInputTooLargeError(stat.size, maxBytes);
|
||||
}
|
||||
|
||||
const inputBuffer = await fs.readFile(resolvedPath);
|
||||
if (inputBuffer.byteLength > maxBytes) {
|
||||
throw new ImageInputTooLargeError(inputBuffer.byteLength, maxBytes);
|
||||
}
|
||||
|
||||
let outputData = Buffer.from(inputBuffer).toBase64();
|
||||
let outputMimeType = mimeType;
|
||||
let outputBytes = inputBuffer.byteLength;
|
||||
let dimensionNote: string | undefined;
|
||||
|
||||
if (options.autoResize) {
|
||||
try {
|
||||
const resized = await resizeImage({ type: "image", data: outputData, mimeType });
|
||||
outputData = resized.data;
|
||||
outputMimeType = resized.mimeType;
|
||||
outputBytes = resized.buffer.byteLength;
|
||||
dimensionNote = formatDimensionNote(resized);
|
||||
} catch {
|
||||
// keep original image when resize fails
|
||||
}
|
||||
}
|
||||
|
||||
let textNote = `Read image file [${outputMimeType}]`;
|
||||
if (dimensionNote) {
|
||||
textNote += `\n${dimensionNote}`;
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedPath,
|
||||
mimeType: outputMimeType,
|
||||
data: outputData,
|
||||
textNote,
|
||||
dimensionNote,
|
||||
bytes: outputBytes,
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
|
||||
const FILE_TYPE_SNIFF_BYTES = 12;
|
||||
|
||||
function detectMimeFromBytes(buf: Buffer, bytesRead: number): string | null {
|
||||
if (bytesRead >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (
|
||||
bytesRead >= 8 &&
|
||||
buf[0] === 0x89 &&
|
||||
buf[1] === 0x50 &&
|
||||
buf[2] === 0x4e &&
|
||||
buf[3] === 0x47 &&
|
||||
buf[4] === 0x0d &&
|
||||
buf[5] === 0x0a &&
|
||||
buf[6] === 0x1a &&
|
||||
buf[7] === 0x0a
|
||||
) {
|
||||
return "image/png";
|
||||
}
|
||||
if (bytesRead >= 4 && buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) {
|
||||
return "image/gif";
|
||||
}
|
||||
if (
|
||||
bytesRead >= 12 &&
|
||||
buf[0] === 0x52 &&
|
||||
buf[1] === 0x49 &&
|
||||
buf[2] === 0x46 &&
|
||||
buf[3] === 0x46 &&
|
||||
buf[8] === 0x57 &&
|
||||
buf[9] === 0x45 &&
|
||||
buf[10] === 0x42 &&
|
||||
buf[11] === 0x50
|
||||
) {
|
||||
return "image/webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<string | null> {
|
||||
const fileHandle = await fs.open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(FILE_TYPE_SNIFF_BYTES);
|
||||
const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0);
|
||||
if (bytesRead === 0) {
|
||||
return null;
|
||||
}
|
||||
return detectMimeFromBytes(buffer, bytesRead);
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as imageConvert from "../src/utils/image-convert";
|
||||
import { ensureSupportedImageInput } from "../src/utils/image-input";
|
||||
import { ensureSupportedImageInput } from "../src/utils/image-loading";
|
||||
|
||||
describe("ensureSupportedImageInput", () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { readImageMetadata } from "@oh-my-pi/pi-coding-agent/utils/image-input";
|
||||
import { readImageMetadata } from "@oh-my-pi/pi-utils";
|
||||
|
||||
describe("readImageMetadata", () => {
|
||||
let testDir: string;
|
||||
@@ -23,10 +23,9 @@ describe("readImageMetadata", () => {
|
||||
const imagePath = path.join(testDir, "header-only.png");
|
||||
fs.writeFileSync(imagePath, pngHeader);
|
||||
|
||||
const metadata = await readImageMetadata({ path: imagePath, cwd: testDir });
|
||||
const metadata = await readImageMetadata(imagePath);
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata?.mimeType).toBe("image/png");
|
||||
expect(metadata?.bytes).toBe(pngHeader.byteLength);
|
||||
expect(metadata?.width).toBe(4);
|
||||
expect(metadata?.height).toBe(3);
|
||||
expect(metadata?.channels).toBe(4);
|
||||
@@ -41,10 +40,9 @@ describe("readImageMetadata", () => {
|
||||
const imagePath = path.join(testDir, "header-only.jpg");
|
||||
fs.writeFileSync(imagePath, jpegHeader);
|
||||
|
||||
const metadata = await readImageMetadata({ path: imagePath, cwd: testDir });
|
||||
const metadata = await readImageMetadata(imagePath);
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata?.mimeType).toBe("image/jpeg");
|
||||
expect(metadata?.bytes).toBe(jpegHeader.byteLength);
|
||||
expect(metadata?.width).toBe(3);
|
||||
expect(metadata?.height).toBe(2);
|
||||
expect(metadata?.channels).toBe(3);
|
||||
@@ -55,7 +53,7 @@ describe("readImageMetadata", () => {
|
||||
const textPath = path.join(testDir, "not-image.bin");
|
||||
fs.writeFileSync(textPath, "plain text");
|
||||
|
||||
const metadata = await readImageMetadata({ path: textPath, cwd: testDir });
|
||||
const metadata = await readImageMetadata(textPath);
|
||||
expect(metadata).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user