import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Jimp, JimpMime } from "jimp"; import decodeWebp, { init as initWebpDecode } from "@jsquash/webp/decode.js"; export interface WechatUploadAsset { buffer: Buffer; filename: string; contentType: string; fileExt: string; fileSize: number; } export interface PreparedWechatUploadAsset { buffer: Buffer; filename: string; contentType: string; wasProcessed: boolean; processingNotes: string[]; } export const WECHAT_BODY_IMAGE_MAX_SIZE = 1024 * 1024; // 1MB export const WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS = new Set([ ".gif", ".webp", ".bmp", ".tiff", ".tif", ".svg", ".ico", ]); const BODY_UPLOAD_ALLOWED_MIME_TYPES = new Set([ JimpMime.jpeg, JimpMime.png, ]); const MIME_TO_EXT: Record = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/bmp": ".bmp", "image/x-ms-bmp": ".bmp", "image/tiff": ".tiff", "image/svg+xml": ".svg", "image/x-icon": ".ico", "image/vnd.microsoft.icon": ".ico", }; const JPEG_QUALITY_STEPS = [82, 74, 66, 58, 50, 42, 34]; const MAX_WIDTH_STEPS = [2560, 2048, 1600, 1280, 1024, 800, 640, 480]; /** * Detect actual image format from buffer magic bytes. * Returns corrected { contentType, fileExt } or null if unknown. */ export function detectImageFormatFromBuffer(buffer: Buffer): { contentType: string; fileExt: string } | null { if (buffer.length < 12) return null; // WebP: RIFF....WEBP if ( buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50 ) { return { contentType: "image/webp", fileExt: ".webp" }; } // PNG: 89 50 4E 47 if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) { return { contentType: "image/png", fileExt: ".png" }; } // JPEG: FF D8 FF if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { return { contentType: "image/jpeg", fileExt: ".jpg" }; } // GIF: GIF8 if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) { return { contentType: "image/gif", fileExt: ".gif" }; } // BMP: BM if (buffer[0] === 0x42 && buffer[1] === 0x4d) { return { contentType: "image/bmp", fileExt: ".bmp" }; } return null; } let webpDecoderReady: Promise | undefined; type JimpImage = Awaited>; function normalizeMimeType(contentType: string): string { return contentType.split(";")[0]!.trim().toLowerCase(); } function extFromMimeType(contentType: string): string { return MIME_TO_EXT[normalizeMimeType(contentType)] || ""; } function ensureFileExt(asset: WechatUploadAsset): string { return asset.fileExt || extFromMimeType(asset.contentType); } function basenameWithoutExt(filename: string): string { const base = path.basename(filename, path.extname(filename)); return base || "image"; } function renameWithExt(filename: string, ext: string): string { return `${basenameWithoutExt(filename)}${ext}`; } export function needsWechatBodyImageProcessing(asset: WechatUploadAsset): boolean { if (asset.fileSize > WECHAT_BODY_IMAGE_MAX_SIZE) { return true; } const normalizedMimeType = normalizeMimeType(asset.contentType); if (BODY_UPLOAD_ALLOWED_MIME_TYPES.has(normalizedMimeType)) { return false; } const fileExt = ensureFileExt(asset); return WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS.has(fileExt) || !fileExt; } async function ensureWebpDecoder(): Promise { if (!webpDecoderReady) { webpDecoderReady = (async () => { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const wasmPath = path.resolve(__dirname, "node_modules/@jsquash/webp/codec/dec/webp_dec.wasm"); const wasmModule = await WebAssembly.compile(await fs.readFile(wasmPath)); await initWebpDecode(wasmModule, {}); })(); } await webpDecoderReady; } async function loadImageForProcessing(asset: WechatUploadAsset): Promise { const fileExt = ensureFileExt(asset); const normalizedMimeType = normalizeMimeType(asset.contentType); if (fileExt === ".webp" || normalizedMimeType === "image/webp") { await ensureWebpDecoder(); const decoded = await decodeWebp(asset.buffer); return new Jimp({ data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength), width: decoded.width, height: decoded.height, }); } if (fileExt === ".svg" || fileExt === ".ico") { throw new Error(`Cannot convert ${fileExt} image for WeChat body upload; provide a PNG or JPG instead.`); } return Jimp.read(asset.buffer); } function imageHasTransparency(image: JimpImage): boolean { const { data } = image.bitmap; for (let i = 3; i < data.length; i += 4) { if (data[i] !== 255) { return true; } } return false; } function buildCandidateWidths(width: number): number[] { const candidates = new Set([width]); for (const maxWidth of MAX_WIDTH_STEPS) { if (width > maxWidth) { candidates.add(maxWidth); } } return [...candidates].sort((a, b) => b - a); } function resizeToWidth(image: JimpImage, width: number): JimpImage { const cloned = image.clone(); if (width < image.bitmap.width) { cloned.resize({ w: width }); } return cloned; } function flattenOnWhite(image: JimpImage): JimpImage { const flattened = new Jimp({ width: image.bitmap.width, height: image.bitmap.height, color: 0xffffffff, }); flattened.composite(image, 0, 0); return flattened; } async function encodePng(image: JimpImage): Promise { return image.getBuffer(JimpMime.png); } async function encodeJpeg(image: JimpImage, quality: number): Promise { const jpegSource = imageHasTransparency(image) ? flattenOnWhite(image) : image; return jpegSource.getBuffer(JimpMime.jpeg, { quality }); } function buildProcessingNotes(asset: WechatUploadAsset): string[] { const notes: string[] = []; const fileExt = ensureFileExt(asset); if (fileExt && WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS.has(fileExt)) { notes.push(`converted unsupported ${fileExt} source`); } if (asset.fileSize > WECHAT_BODY_IMAGE_MAX_SIZE) { notes.push(`compressed ${(asset.fileSize / 1024 / 1024).toFixed(2)}MB source below 1MB`); } if (notes.length === 0) { notes.push("re-encoded for WeChat body upload"); } return notes; } export async function prepareWechatBodyImageUpload( asset: WechatUploadAsset, ): Promise { if (!needsWechatBodyImageProcessing(asset)) { return { buffer: asset.buffer, filename: asset.filename, contentType: asset.contentType, wasProcessed: false, processingNotes: [], }; } const image = await loadImageForProcessing(asset); const widths = buildCandidateWidths(image.bitmap.width); const ext = ensureFileExt(asset); const preferPng = imageHasTransparency(image) || ext === ".png" || ext === ".webp"; const processingNotes = buildProcessingNotes(asset); for (const width of widths) { const resized = resizeToWidth(image, width); if (preferPng) { const pngBuffer = await encodePng(resized); if (pngBuffer.length <= WECHAT_BODY_IMAGE_MAX_SIZE) { return { buffer: pngBuffer, filename: renameWithExt(asset.filename, ".png"), contentType: JimpMime.png, wasProcessed: true, processingNotes: width < image.bitmap.width ? [...processingNotes, `resized to ${width}px wide`] : processingNotes, }; } } for (const quality of JPEG_QUALITY_STEPS) { const jpegBuffer = await encodeJpeg(resized, quality); if (jpegBuffer.length <= WECHAT_BODY_IMAGE_MAX_SIZE) { const notes = [...processingNotes, `encoded as JPEG (${quality} quality)`]; if (width < image.bitmap.width) { notes.push(`resized to ${width}px wide`); } return { buffer: jpegBuffer, filename: renameWithExt(asset.filename, ".jpg"), contentType: JimpMime.jpeg, wasProcessed: true, processingNotes: notes, }; } } } throw new Error(`Unable to reduce ${asset.filename} below 1MB for WeChat body upload.`); }