blob: 3b4d071f6f4b8f3784830e05eff69dda6e0c3395 [file]
import { stat, readFile } from 'node:fs/promises';
import { extname } from 'node:path';
import { imageDimensionsFromData } from 'image-dimensions';
import {
MAX_MODEL_IMAGE_EDGE,
MAX_READ_IMAGE_BYTES,
READ_IMAGE_TOO_LARGE_MESSAGE,
} from '@maka/core';
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
const PNG_SIGNATURE = Buffer.from('\x89PNG\r\n\x1a\n', 'latin1');
const JPEG_SIGNATURE = Buffer.from('\xff\xd8\xff', 'latin1');
const GIF87A_SIGNATURE = Buffer.from('GIF87a');
const GIF89A_SIGNATURE = Buffer.from('GIF89a');
const RIFF_SIGNATURE = Buffer.from('RIFF');
const WEBP_SIGNATURE = Buffer.from('WEBP');
export type ImageMimeType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
export function isSupportedImagePath(path: string): boolean {
return IMAGE_EXTENSIONS.has(extname(path).toLowerCase());
}
export async function readWorkspaceImage(
path: string,
): Promise<{ bytes: Uint8Array; mimeType: ImageMimeType }> {
const size = await stat(path).catch(() => {
throw new Error('Image could not be read.');
});
if (!size.isFile()) throw new Error('Image path is not a file.');
if (size.size > MAX_READ_IMAGE_BYTES) throw imageTooLargeError();
const bytes = await readFile(path).catch(() => {
throw new Error('Image could not be read.');
});
return validateImageBytes(bytes);
}
export function validateImageBytes(bytes: Uint8Array): {
bytes: Uint8Array;
mimeType: ImageMimeType;
} {
if (bytes.length > MAX_READ_IMAGE_BYTES) throw imageTooLargeError();
const mimeType = sniffImageMime(bytes);
if (!mimeType) throw new Error('Image content is not a supported PNG, JPEG, GIF, or WebP file.');
const dimensions = imageDimensionsFromData(bytes);
if (
!dimensions ||
!Number.isFinite(dimensions.width) ||
!Number.isFinite(dimensions.height) ||
!Number.isInteger(dimensions.width) ||
!Number.isInteger(dimensions.height) ||
dimensions.width <= 0 ||
dimensions.height <= 0
) {
throw new Error('Image dimensions could not be read; verify the image file is valid.');
}
if (Math.max(dimensions.width, dimensions.height) > MAX_MODEL_IMAGE_EDGE) {
throw new Error(
`Image dimensions ${dimensions.width}x${dimensions.height} exceed the ${MAX_MODEL_IMAGE_EDGE}px model input limit; downscale it and try again.`,
);
}
return { bytes, mimeType };
}
function imageTooLargeError(): Error {
return new Error(READ_IMAGE_TOO_LARGE_MESSAGE);
}
function sniffImageMime(bytes: Uint8Array): ImageMimeType | undefined {
if (startsWith(bytes, PNG_SIGNATURE)) return 'image/png';
if (startsWith(bytes, JPEG_SIGNATURE)) return 'image/jpeg';
if (startsWith(bytes, GIF87A_SIGNATURE) || startsWith(bytes, GIF89A_SIGNATURE))
return 'image/gif';
if (startsWith(bytes, RIFF_SIGNATURE) && startsWith(bytes, WEBP_SIGNATURE, 8))
return 'image/webp';
return undefined;
}
function startsWith(bytes: Uint8Array, prefix: Uint8Array, offset = 0): boolean {
return (
bytes.length >= offset + prefix.length &&
prefix.every((value, index) => bytes[offset + index] === value)
);
}