FE_Dev / server /utils /image-optimizer.ts
GitHub Actions
Deploy from GitHub Actions [dev] - 2025-10-31 07:28:50
68f7925
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<Buffer> {
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<string> {
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<string> {
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<string> {
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<sharp.Metadata> {
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);
}
}