Files
oh-my-pi/packages/coding-agent/src/utils/image-input.ts
T
2026-03-26 15:28:04 +01:00

275 lines
8.5 KiB
TypeScript

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,
};
}