import * as fs from 'node:fs'; import * as crypto from 'node:crypto'; import * as stream from 'node:stream'; import * as util from 'node:util'; import { fileTypeFromFile } from 'file-type'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; import sharp from 'sharp'; import { encode } from 'blurhash'; const pipeline = util.promisify(stream.pipeline); export type FileInfo = { size: number; md5: string; type: { mime: string; ext: string | null; }; width?: number; height?: number; orientation?: number; blurhash?: string; warnings: string[]; }; const TYPE_OCTET_STREAM = { mime: 'application/octet-stream', ext: null, }; const TYPE_SVG = { mime: 'image/svg+xml', ext: 'svg', }; /** * Get file information */ export async function getFileInfo(path: string): Promise { const warnings = [] as string[]; const size = await getFileSize(path); const md5 = await calcHash(path); let type = await detectType(path); // image dimensions let width: number | undefined; let height: number | undefined; let orientation: number | undefined; if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) { const imageSize = await detectImageSize(path).catch(e => { warnings.push(`detectImageSize failed: ${e}`); return undefined; }); // うまく判定できない画像は octet-stream にする if (!imageSize) { warnings.push(`cannot detect image dimensions`); type = TYPE_OCTET_STREAM; } else if (imageSize.wUnits === 'px') { width = imageSize.width; height = imageSize.height; orientation = imageSize.orientation; // 制限を超えている画像は octet-stream にする if (imageSize.width > 16383 || imageSize.height > 16383) { warnings.push(`image dimensions exceeds limits`); type = TYPE_OCTET_STREAM; } } else { warnings.push(`unsupported unit type: ${imageSize.wUnits}`); } } let blurhash: string | undefined; if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) { blurhash = await getBlurhash(path).catch(e => { warnings.push(`getBlurhash failed: ${e}`); return undefined; }); } return { size, md5, type, width, height, orientation, blurhash, warnings, }; } /** * Detect MIME Type and extension */ export async function detectType(path: string): Promise<{ mime: string; ext: string | null; }> { // Check 0 byte const fileSize = await getFileSize(path); if (fileSize === 0) { return TYPE_OCTET_STREAM; } const type = await fileTypeFromFile(path); if (type) { // XMLはSVGかもしれない if (type.mime === 'application/xml' && await checkSvg(path)) { return TYPE_SVG; } return { mime: type.mime, ext: type.ext, }; } // 種類が不明でもSVGかもしれない if (await checkSvg(path)) { return TYPE_SVG; } // それでも種類が不明なら application/octet-stream にする return TYPE_OCTET_STREAM; } /** * Check the file is SVG or not */ export async function checkSvg(path: string) { try { const size = await getFileSize(path); if (size > 1 * 1024 * 1024) return false; return isSvg(fs.readFileSync(path)); } catch { return false; } } /** * Get file size */ export async function getFileSize(path: string): Promise { const getStat = util.promisify(fs.stat); return (await getStat(path)).size; } /** * Calculate MD5 hash */ async function calcHash(path: string): Promise { const hash = crypto.createHash('md5').setEncoding('hex'); await pipeline(fs.createReadStream(path), hash); return hash.read(); } /** * Detect dimensions of image */ async function detectImageSize(path: string): Promise<{ width: number; height: number; wUnits: string; hUnits: string; orientation?: number; }> { const readable = fs.createReadStream(path); const imageSize = await probeImageSize(readable); readable.destroy(); return imageSize; } /** * Calculate average color of image */ function getBlurhash(path: string): Promise { return new Promise((resolve, reject) => { sharp(path) .raw() .ensureAlpha() .resize(64, 64, { fit: 'inside' }) .toBuffer((err, buffer, { width, height }) => { if (err) return reject(err); let hash; try { hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7); } catch (e) { return reject(e); } resolve(hash); }); }); }