import sharp from 'sharp'; export interface ImageOptimizationOptions { quality?: number; format?: 'webp' | 'jpeg' | 'png'; width?: number; height?: number; cropToCenter?: boolean; cropHeight?: number; } export interface ContentImageOptions { quality?: number; targetWidth?: number; targetHeight?: number; } export class ImageOptimizer { /** * データURLをBufferに変換 */ static dataUrlToBuffer(dataUrl: string): Buffer { const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, ''); return Buffer.from(base64Data, 'base64'); } /** * BufferをデータURLに変換 */ static bufferToDataUrl(buffer: Buffer, mimeType: string = 'image/webp'): string { return `data:${mimeType};base64,${buffer.toString('base64')}`; } /** * 画像をWebP形式に圧縮 */ static async compressToWebP(input: Buffer | string, quality: number = 65): Promise { const buffer = typeof input === 'string' ? this.dataUrlToBuffer(input) : input; return await sharp(buffer).webp({ quality }).toBuffer(); } /** * FV画像用の最適化(WebP変換のみ) */ static async optimizeFvImage(input: Buffer | string, options: ImageOptimizationOptions = {}): Promise { const { quality = 65, format = 'webp' } = options; const buffer = typeof input === 'string' ? this.dataUrlToBuffer(input) : input; let processedBuffer: Buffer; if (format === 'webp') { processedBuffer = await sharp(buffer).webp({ quality }).toBuffer(); } else if (format === 'jpeg') { processedBuffer = await sharp(buffer).jpeg({ quality }).toBuffer(); } else { processedBuffer = await sharp(buffer).png({ quality }).toBuffer(); } const mimeType = format === 'webp' ? 'image/webp' : format === 'jpeg' ? 'image/jpeg' : 'image/png'; return this.bufferToDataUrl(processedBuffer, mimeType); } /** * コンテンツ画像用の最適化(リサイズ + クロップ + WebP変換) */ static async optimizeContentImage(input: Buffer | string, options: ContentImageOptions = {}): Promise { const { quality = 50, // 圧縮率を50%に変更 targetWidth = 400, // 幅を400pxに縮小 targetHeight = 300, // 高さを300pxに縮小 } = options; const buffer = typeof input === 'string' ? this.dataUrlToBuffer(input) : input; // まず400x400にリサイズ const resizedBuffer = await sharp(buffer) .resize(400, 400, { fit: 'cover', position: 'center', }) .toBuffer(); // 上下50pxをクロップして400x300に const croppedBuffer = await sharp(resizedBuffer) .extract({ left: 0, top: 50, // クロップ位置も調整 width: targetWidth, height: targetHeight, }) .webp({ quality }) .toBuffer(); return this.bufferToDataUrl(croppedBuffer, 'image/webp'); } /** * 汎用的な画像最適化メソッド */ static async optimizeImage(input: Buffer | string, options: ImageOptimizationOptions = {}): Promise { const { quality = 65, format = 'webp', width, height, cropToCenter = false, cropHeight } = options; const buffer = typeof input === 'string' ? this.dataUrlToBuffer(input) : input; let pipeline = sharp(buffer); // リサイズが指定されている場合 if (width || height) { pipeline = pipeline.resize(width, height, { fit: cropToCenter ? 'cover' : 'inside', position: 'center', }); } // クロップが指定されている場合 if (cropToCenter && width && cropHeight) { const metadata = await sharp(buffer).metadata(); const currentHeight = metadata.height || 0; if (currentHeight > cropHeight) { const topOffset = Math.floor((currentHeight - cropHeight) / 2); pipeline = pipeline.extract({ left: 0, top: topOffset, width: width, height: cropHeight, }); } } // フォーマット変換 let processedBuffer: Buffer; if (format === 'webp') { processedBuffer = await pipeline.webp({ quality }).toBuffer(); } else if (format === 'jpeg') { processedBuffer = await pipeline.jpeg({ quality }).toBuffer(); } else { processedBuffer = await pipeline.png({ quality }).toBuffer(); } const mimeType = format === 'webp' ? 'image/webp' : format === 'jpeg' ? 'image/jpeg' : 'image/png'; return this.bufferToDataUrl(processedBuffer, mimeType); } /** * 画像のメタデータを取得 */ static async getImageMetadata(input: Buffer | string): Promise { const buffer = typeof input === 'string' ? this.dataUrlToBuffer(input) : input; return await sharp(buffer).metadata(); } /** * 画像サイズの計算(圧縮前後の比較用) */ static calculateDataUrlSize(dataUrl: string): number { const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, ''); return Buffer.from(base64Data, 'base64').length; } /** * 圧縮率の計算 */ static calculateCompressionRatio(originalSize: number, compressedSize: number): number { return Math.round((1 - compressedSize / originalSize) * 100); } }