Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Post articles and image-text content to WeChat Official Account via API or Chrome CDP, with markdown-to-WeChat HTML conversion.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/wechat-image-processor.ts
1import fs from "node:fs/promises";2import path from "node:path";3import { fileURLToPath } from "node:url";4import { Jimp, JimpMime } from "jimp";5import decodeWebp, { init as initWebpDecode } from "@jsquash/webp/decode.js";67export interface WechatUploadAsset {8buffer: Buffer;9filename: string;10contentType: string;11fileExt: string;12fileSize: number;13}1415export interface PreparedWechatUploadAsset {16buffer: Buffer;17filename: string;18contentType: string;19wasProcessed: boolean;20processingNotes: string[];21}2223export const WECHAT_BODY_IMAGE_MAX_SIZE = 1024 * 1024; // 1MB24export const WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS = new Set([25".gif",26".webp",27".bmp",28".tiff",29".tif",30".svg",31".ico",32]);3334const BODY_UPLOAD_ALLOWED_MIME_TYPES = new Set([35JimpMime.jpeg,36JimpMime.png,37]);3839const MIME_TO_EXT: Record<string, string> = {40"image/jpeg": ".jpg",41"image/png": ".png",42"image/gif": ".gif",43"image/webp": ".webp",44"image/bmp": ".bmp",45"image/x-ms-bmp": ".bmp",46"image/tiff": ".tiff",47"image/svg+xml": ".svg",48"image/x-icon": ".ico",49"image/vnd.microsoft.icon": ".ico",50};5152const JPEG_QUALITY_STEPS = [82, 74, 66, 58, 50, 42, 34];53const MAX_WIDTH_STEPS = [2560, 2048, 1600, 1280, 1024, 800, 640, 480];5455/**56* Detect actual image format from buffer magic bytes.57* Returns corrected { contentType, fileExt } or null if unknown.58*/59export function detectImageFormatFromBuffer(buffer: Buffer): { contentType: string; fileExt: string } | null {60if (buffer.length < 12) return null;6162// WebP: RIFF....WEBP63if (64buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&65buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x5066) {67return { contentType: "image/webp", fileExt: ".webp" };68}69// PNG: 89 50 4E 4770if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {71return { contentType: "image/png", fileExt: ".png" };72}73// JPEG: FF D8 FF74if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {75return { contentType: "image/jpeg", fileExt: ".jpg" };76}77// GIF: GIF878if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {79return { contentType: "image/gif", fileExt: ".gif" };80}81// BMP: BM82if (buffer[0] === 0x42 && buffer[1] === 0x4d) {83return { contentType: "image/bmp", fileExt: ".bmp" };84}85return null;86}8788let webpDecoderReady: Promise<void> | undefined;8990type JimpImage = Awaited<ReturnType<typeof Jimp.read>>;9192function normalizeMimeType(contentType: string): string {93return contentType.split(";")[0]!.trim().toLowerCase();94}9596function extFromMimeType(contentType: string): string {97return MIME_TO_EXT[normalizeMimeType(contentType)] || "";98}99100function ensureFileExt(asset: WechatUploadAsset): string {101return asset.fileExt || extFromMimeType(asset.contentType);102}103104function basenameWithoutExt(filename: string): string {105const base = path.basename(filename, path.extname(filename));106return base || "image";107}108109function renameWithExt(filename: string, ext: string): string {110return `${basenameWithoutExt(filename)}${ext}`;111}112113export function needsWechatBodyImageProcessing(asset: WechatUploadAsset): boolean {114if (asset.fileSize > WECHAT_BODY_IMAGE_MAX_SIZE) {115return true;116}117118const normalizedMimeType = normalizeMimeType(asset.contentType);119if (BODY_UPLOAD_ALLOWED_MIME_TYPES.has(normalizedMimeType)) {120return false;121}122123const fileExt = ensureFileExt(asset);124return WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS.has(fileExt) || !fileExt;125}126127async function ensureWebpDecoder(): Promise<void> {128if (!webpDecoderReady) {129webpDecoderReady = (async () => {130const __filename = fileURLToPath(import.meta.url);131const __dirname = path.dirname(__filename);132const wasmPath = path.resolve(__dirname, "node_modules/@jsquash/webp/codec/dec/webp_dec.wasm");133const wasmModule = await WebAssembly.compile(await fs.readFile(wasmPath));134await initWebpDecode(wasmModule, {});135})();136}137138await webpDecoderReady;139}140141async function loadImageForProcessing(asset: WechatUploadAsset): Promise<JimpImage> {142const fileExt = ensureFileExt(asset);143const normalizedMimeType = normalizeMimeType(asset.contentType);144145if (fileExt === ".webp" || normalizedMimeType === "image/webp") {146await ensureWebpDecoder();147const decoded = await decodeWebp(asset.buffer);148return new Jimp({149data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),150width: decoded.width,151height: decoded.height,152});153}154155if (fileExt === ".svg" || fileExt === ".ico") {156throw new Error(`Cannot convert ${fileExt} image for WeChat body upload; provide a PNG or JPG instead.`);157}158159return Jimp.read(asset.buffer);160}161162function imageHasTransparency(image: JimpImage): boolean {163const { data } = image.bitmap;164for (let i = 3; i < data.length; i += 4) {165if (data[i] !== 255) {166return true;167}168}169return false;170}171172function buildCandidateWidths(width: number): number[] {173const candidates = new Set<number>([width]);174175for (const maxWidth of MAX_WIDTH_STEPS) {176if (width > maxWidth) {177candidates.add(maxWidth);178}179}180181return [...candidates].sort((a, b) => b - a);182}183184function resizeToWidth(image: JimpImage, width: number): JimpImage {185const cloned = image.clone();186if (width < image.bitmap.width) {187cloned.resize({ w: width });188}189return cloned;190}191192function flattenOnWhite(image: JimpImage): JimpImage {193const flattened = new Jimp({194width: image.bitmap.width,195height: image.bitmap.height,196color: 0xffffffff,197});198flattened.composite(image, 0, 0);199return flattened;200}201202async function encodePng(image: JimpImage): Promise<Buffer> {203return image.getBuffer(JimpMime.png);204}205206async function encodeJpeg(image: JimpImage, quality: number): Promise<Buffer> {207const jpegSource = imageHasTransparency(image) ? flattenOnWhite(image) : image;208return jpegSource.getBuffer(JimpMime.jpeg, { quality });209}210211function buildProcessingNotes(asset: WechatUploadAsset): string[] {212const notes: string[] = [];213const fileExt = ensureFileExt(asset);214215if (fileExt && WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS.has(fileExt)) {216notes.push(`converted unsupported ${fileExt} source`);217}218219if (asset.fileSize > WECHAT_BODY_IMAGE_MAX_SIZE) {220notes.push(`compressed ${(asset.fileSize / 1024 / 1024).toFixed(2)}MB source below 1MB`);221}222223if (notes.length === 0) {224notes.push("re-encoded for WeChat body upload");225}226227return notes;228}229230export async function prepareWechatBodyImageUpload(231asset: WechatUploadAsset,232): Promise<PreparedWechatUploadAsset> {233if (!needsWechatBodyImageProcessing(asset)) {234return {235buffer: asset.buffer,236filename: asset.filename,237contentType: asset.contentType,238wasProcessed: false,239processingNotes: [],240};241}242243const image = await loadImageForProcessing(asset);244const widths = buildCandidateWidths(image.bitmap.width);245const ext = ensureFileExt(asset);246const preferPng = imageHasTransparency(image) || ext === ".png" || ext === ".webp";247const processingNotes = buildProcessingNotes(asset);248249for (const width of widths) {250const resized = resizeToWidth(image, width);251252if (preferPng) {253const pngBuffer = await encodePng(resized);254if (pngBuffer.length <= WECHAT_BODY_IMAGE_MAX_SIZE) {255return {256buffer: pngBuffer,257filename: renameWithExt(asset.filename, ".png"),258contentType: JimpMime.png,259wasProcessed: true,260processingNotes: width < image.bitmap.width261? [...processingNotes, `resized to ${width}px wide`]262: processingNotes,263};264}265}266267for (const quality of JPEG_QUALITY_STEPS) {268const jpegBuffer = await encodeJpeg(resized, quality);269if (jpegBuffer.length <= WECHAT_BODY_IMAGE_MAX_SIZE) {270const notes = [...processingNotes, `encoded as JPEG (${quality} quality)`];271if (width < image.bitmap.width) {272notes.push(`resized to ${width}px wide`);273}274return {275buffer: jpegBuffer,276filename: renameWithExt(asset.filename, ".jpg"),277contentType: JimpMime.jpeg,278wasProcessed: true,279processingNotes: notes,280};281}282}283}284285throw new Error(`Unable to reduce ${asset.filename} below 1MB for WeChat body upload.`);286}287