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:
can1357
2026-04-08 14:42:51 +02:00
parent 78428b58b4
commit 211e5369a0
16 changed files with 449 additions and 361 deletions
+11
View File
@@ -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";
+6 -16
View File
@@ -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,
};
}
-53
View File
@@ -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();
});
});
+2
View File
@@ -11,6 +11,8 @@ export * from "./hook-fetch";
export * from "./json";
export * as logger from "./logger";
export * from "./mermaid-ascii";
export * from "./mime";
export * from "./peek-file";
export * as postmortem from "./postmortem";
export * as procmgr from "./procmgr";
export { setNativeKillTree } from "./procmgr";
+159
View File
@@ -0,0 +1,159 @@
import { peekFile, peekFileSync } from "./peek-file";
const DEFAULT_IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
const WEBP_RIFF_MAGIC = Buffer.from([0x52, 0x49, 0x46, 0x46]);
const WEBP_MAGIC = Buffer.from([0x57, 0x45, 0x42, 0x50]);
const PNG_IHDR = Buffer.from("IHDR");
const GIF87A = Buffer.from("GIF87a");
const GIF89A = Buffer.from("GIF89a");
const WEBP_VP8X = Buffer.from("VP8X");
const WEBP_VP8L = Buffer.from("VP8L");
const WEBP_VP8 = Buffer.from("VP8 ");
export const SUPPORTED_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
export type ImageMetadata =
| { mimeType: "image/png"; width?: number; height?: number; channels?: number; hasAlpha?: boolean }
| { mimeType: "image/jpeg"; width?: number; height?: number; channels?: number; hasAlpha?: false }
| { mimeType: "image/gif"; width?: number; height?: number; channels?: 3; hasAlpha?: never }
| { mimeType: "image/webp"; width?: number; height?: number; channels?: number; hasAlpha?: boolean };
function magicEquals(header: Uint8Array, offset: number, magic: Buffer): boolean {
if (header.length < offset + magic.length) {
return false;
}
return magic.equals(header.subarray(offset, offset + magic.length));
}
function parsePngMetadata(header: Uint8Array): ImageMetadata | null {
if (!magicEquals(header, 0, PNG_MAGIC)) return null;
if (!magicEquals(header, 12, PNG_IHDR)) return { mimeType: "image/png" };
if (header.length < 26) return { mimeType: "image/png" };
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
const width = view.getUint32(16, false);
const height = view.getUint32(20, false);
const colorType = view.getUint8(25);
if (colorType === 0) return { mimeType: "image/png", width, height, channels: 1, hasAlpha: false };
if (colorType === 2) return { mimeType: "image/png", width, height, channels: 3, hasAlpha: false };
if (colorType === 3) return { mimeType: "image/png", width, height, channels: 3 };
if (colorType === 4) return { mimeType: "image/png", width, height, channels: 2, hasAlpha: true };
if (colorType === 6) return { mimeType: "image/png", width, height, channels: 4, hasAlpha: true };
return { mimeType: "image/png", width, height };
}
function parseJpegMetadata(header: Uint8Array): ImageMetadata | null {
if (!magicEquals(header, 0, JPEG_MAGIC)) return null;
if (header.length < 4) return { mimeType: "image/jpeg" };
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
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 || (marker >= 0xd0 && marker <= 0xd7)) {
offset = segmentOffset;
continue;
}
if (segmentOffset + 1 >= header.length) break;
const segmentLength = view.getUint16(segmentOffset, false);
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 = view.getUint16(segmentOffset + 3, false);
const width = view.getUint16(segmentOffset + 5, false);
const channels = header[segmentOffset + 7];
return {
mimeType: "image/jpeg",
width,
height,
channels: Number.isFinite(channels) ? channels : undefined,
hasAlpha: false,
};
}
offset = segmentOffset + segmentLength;
}
return { mimeType: "image/jpeg" };
}
function parseGifMetadata(header: Uint8Array): ImageMetadata | null {
if (!magicEquals(header, 0, GIF87A) && !magicEquals(header, 0, GIF89A)) return null;
if (header.length < 10) return { mimeType: "image/gif" };
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
return {
mimeType: "image/gif",
width: view.getUint16(6, true),
height: view.getUint16(8, true),
channels: 3,
};
}
function parseWebpMetadata(header: Uint8Array): ImageMetadata | null {
if (!magicEquals(header, 0, WEBP_RIFF_MAGIC)) return null;
if (!magicEquals(header, 8, WEBP_MAGIC)) return null;
if (header.length < 30) return { mimeType: "image/webp" };
if (magicEquals(header, 12, WEBP_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 { mimeType: "image/webp", width, height, channels: hasAlpha ? 4 : 3, hasAlpha };
}
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
if (magicEquals(header, 12, WEBP_VP8L)) {
if (header.length < 25) return { mimeType: "image/webp" };
const bits = view.getUint32(21, true);
const width = (bits & 0x3fff) + 1;
const height = ((bits >> 14) & 0x3fff) + 1;
const hasAlpha = ((bits >> 28) & 0x1) === 1;
return { mimeType: "image/webp", width, height, channels: hasAlpha ? 4 : 3, hasAlpha };
}
if (magicEquals(header, 12, WEBP_VP8)) {
const width = view.getUint16(26, true) & 0x3fff;
const height = view.getUint16(28, true) & 0x3fff;
return { mimeType: "image/webp", width, height, channels: 3, hasAlpha: false };
}
return { mimeType: "image/webp" };
}
export function parseImageMetadata(header: Uint8Array): ImageMetadata | null {
return (
parsePngMetadata(header) ?? parseJpegMetadata(header) ?? parseGifMetadata(header) ?? parseWebpMetadata(header)
);
}
export function readImageMetadataSync(
filePath: string,
maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
): ImageMetadata | null {
return peekFileSync(filePath, maxBytes, parseImageMetadata);
}
export function readImageMetadata(
filePath: string,
maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
): Promise<ImageMetadata | null> {
return peekFile(filePath, maxBytes, parseImageMetadata);
}
+96
View File
@@ -0,0 +1,96 @@
import * as fs from "node:fs";
const POOLED_BUFFER_SIZE = 512;
const ASYNC_POOL_SIZE = 10;
const MAX_ASYNC_WAITERS = 4;
const INITIAL_SYNC_BUFFER_SIZE = 1024;
const EMPTY_BUFFER = Buffer.alloc(0);
const asyncPool = Array.from({ length: ASYNC_POOL_SIZE }, () => Buffer.allocUnsafe(POOLED_BUFFER_SIZE));
const availableAsyncPoolIndexes = Array.from({ length: ASYNC_POOL_SIZE }, (_, index) => index);
const asyncPoolWaiters: Array<(index: number) => void> = [];
let syncPool = new Uint8Array(INITIAL_SYNC_BUFFER_SIZE);
function acquireAsyncPoolIndex(): Promise<number> | number {
const index = availableAsyncPoolIndexes.pop();
if (index !== undefined) {
return index;
}
if (asyncPoolWaiters.length >= MAX_ASYNC_WAITERS) {
return -1;
}
const { promise, resolve } = Promise.withResolvers<number>();
asyncPoolWaiters.push(resolve);
return promise;
}
function releaseAsyncPoolIndex(index: number): void {
if (index < 0) {
return;
}
const waiter = asyncPoolWaiters.shift();
if (waiter) {
waiter(index);
return;
}
availableAsyncPoolIndexes.push(index);
}
async function withAsyncPoolBuffer<T>(maxBytes: number, op: (buffer: Buffer) => Promise<T>): Promise<T> {
if (maxBytes <= 0) {
return op(EMPTY_BUFFER);
}
if (maxBytes > POOLED_BUFFER_SIZE) {
return op(Buffer.allocUnsafe(maxBytes));
}
const poolIndex = await acquireAsyncPoolIndex();
const buffer = poolIndex >= 0 ? asyncPool[poolIndex] : Buffer.allocUnsafe(maxBytes);
try {
return await op(buffer.subarray(0, maxBytes));
} finally {
releaseAsyncPoolIndex(poolIndex);
}
}
function withSyncPoolBuffer<T>(maxBytes: number, op: (buffer: Uint8Array) => T): T {
if (maxBytes <= 0) {
return op(EMPTY_BUFFER);
}
if (maxBytes > syncPool.byteLength) {
syncPool = new Uint8Array(maxBytes + (maxBytes >> 1));
}
return op(syncPool.subarray(0, maxBytes));
}
export function peekFileSync<T>(filePath: string, maxBytes: number, op: (header: Uint8Array) => T): T {
if (maxBytes <= 0) {
return op(EMPTY_BUFFER);
}
const fileHandle = fs.openSync(filePath, "r");
try {
return withSyncPoolBuffer(maxBytes, buffer => {
const bytesRead = fs.readSync(fileHandle, buffer, 0, buffer.byteLength, 0);
return op(buffer.subarray(0, bytesRead));
});
} finally {
fs.closeSync(fileHandle);
}
}
export async function peekFile<T>(filePath: string, maxBytes: number, op: (header: Uint8Array) => T): Promise<T> {
if (maxBytes <= 0) {
return op(EMPTY_BUFFER);
}
const fileHandle = await fs.promises.open(filePath, "r");
try {
return await withAsyncPoolBuffer(maxBytes, async buffer => {
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
return op(buffer.subarray(0, bytesRead));
});
} finally {
await fileHandle.close();
}
}
+52
View File
@@ -0,0 +1,52 @@
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 { peekFile, peekFileSync } from "../src/peek-file";
function rangeBuffer(length: number): Buffer {
return Buffer.from(Array.from({ length }, (_, index) => index % 256));
}
describe("peekFile", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-peek-file-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("reads an exact header slice asynchronously", async () => {
const filePath = path.join(tempDir, "sample.bin");
const content = rangeBuffer(1024);
fs.writeFileSync(filePath, content);
const header = await peekFile(filePath, 37, bytes => Buffer.from(bytes));
expect(header).toEqual(content.subarray(0, 37));
});
it("reads an exact header slice synchronously", () => {
const filePath = path.join(tempDir, "sample.bin");
const content = rangeBuffer(2048);
fs.writeFileSync(filePath, content);
const header = peekFileSync(filePath, 777, bytes => Buffer.from(bytes));
expect(header).toEqual(content.subarray(0, 777));
});
it("serves concurrent async peeks without corrupting buffers", async () => {
const filePath = path.join(tempDir, "sample.bin");
const content = rangeBuffer(4096);
fs.writeFileSync(filePath, content);
const lengths = [17, 33, 64, 128, 257, 511, 512, 513, 777, 1024, 1536, 2048];
const headers = await Promise.all(lengths.map(length => peekFile(filePath, length, bytes => Buffer.from(bytes))));
expect(headers).toHaveLength(lengths.length);
for (const [index, header] of headers.entries()) {
expect(header).toEqual(content.subarray(0, lengths[index]));
}
});
});