import sharp from 'sharp'; export const VISION_THUMB_MAX_SIDE = 1280; export const VISION_THUMB_MAX_BYTES = 600 * 1024; export const VISION_THUMB_JPEG_QUALITY = 78; /** * Build a smaller JPEG buffer for vision-model analysis only. * Page generation still uses the full public standard image elsewhere. */ export async function buildVisionThumbnailBuffer( buffer, mimeType = 'image/jpeg', { maxSide = VISION_THUMB_MAX_SIDE, maxBytes = VISION_THUMB_MAX_BYTES, quality = VISION_THUMB_JPEG_QUALITY, } = {}, ) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) { throw new Error('vision thumb: empty buffer'); } const metadata = await sharp(buffer, { sequentialRead: true }).metadata(); const width = Number(metadata.width ?? 0); const height = Number(metadata.height ?? 0); if (!width || !height) { throw new Error('vision thumb: invalid dimensions'); } const longest = Math.max(width, height); const resizeWidth = longest > maxSide ? Math.max(1, Math.round((width * maxSide) / longest)) : null; const resizeHeight = longest > maxSide ? Math.max(1, Math.round((height * maxSide) / longest)) : null; let pipeline = sharp(buffer, { sequentialRead: true }).rotate(); if (resizeWidth && resizeHeight) { pipeline = pipeline.resize({ width: resizeWidth, height: resizeHeight, fit: 'inside', withoutEnlargement: true, }); } let nextQuality = quality; let candidate = await pipeline .flatten({ background: '#ffffff' }) .jpeg({ quality: nextQuality, mozjpeg: true }) .toBuffer(); while (candidate.length > maxBytes && nextQuality > 52) { nextQuality = Math.max(52, nextQuality - 8); candidate = await sharp(buffer, { sequentialRead: true }) .rotate() .resize({ width: resizeWidth ?? width, height: resizeHeight ?? height, fit: 'inside', withoutEnlargement: true, }) .flatten({ background: '#ffffff' }) .jpeg({ quality: nextQuality, mozjpeg: true }) .toBuffer(); } return candidate; } export const visionThumbInternals = { VISION_THUMB_MAX_SIDE, VISION_THUMB_MAX_BYTES, VISION_THUMB_JPEG_QUALITY, };