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();
});
});