UNPKG

@restnfeel/agentc-starter-kit

Version:

한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템

456 lines (409 loc) 11.6 kB
import sharp from "sharp"; import path from "path"; import fs from "fs/promises"; // 이미지 크기 사전 정의 export const IMAGE_SIZES = { thumbnail: { width: 150, height: 150 }, small: { width: 400, height: 300 }, medium: { width: 800, height: 600 }, large: { width: 1200, height: 900 }, xlarge: { width: 1920, height: 1440 }, } as const; export type ImageSize = keyof typeof IMAGE_SIZES; // 이미지 품질 설정 export const QUALITY_SETTINGS = { thumbnail: 70, small: 75, medium: 80, large: 85, xlarge: 90, } as const; // 지원하는 이미지 포맷 export const SUPPORTED_FORMATS = [ "jpeg", "jpg", "png", "webp", "avif", ] as const; export type SupportedFormat = (typeof SUPPORTED_FORMATS)[number]; // 이미지 처리 옵션 export interface ImageProcessingOptions { preserveMetadata?: boolean; stripMetadata?: boolean; quality?: number; progressive?: boolean; optimize?: boolean; generateWebP?: boolean; generateAVIF?: boolean; sizes?: ImageSize[]; outputDirectory?: string; } // 처리 결과 export interface ProcessingResult { original: { width: number; height: number; format: string; size: number; path: string; }; processed: Array<{ size: ImageSize; format: string; width: number; height: number; fileSize: number; path: string; url: string; }>; webp?: Array<{ size: ImageSize; width: number; height: number; fileSize: number; path: string; url: string; }>; avif?: Array<{ size: ImageSize; width: number; height: number; fileSize: number; path: string; url: string; }>; metadata: { colorSpace?: string; density?: number; channels?: number; hasProfile?: boolean; hasAlpha?: boolean; }; } // 이미지 프로세서 클래스 export class ImageProcessor { private basePath: string; private baseUrl: string; constructor(basePath = "public/uploads", baseUrl = "/uploads") { this.basePath = basePath; this.baseUrl = baseUrl; } /** * 이미지 메타데이터 추출 */ async getImageMetadata(filePath: string) { try { const metadata = await sharp(filePath).metadata(); return { width: metadata.width || 0, height: metadata.height || 0, format: metadata.format || "unknown", colorSpace: metadata.space, density: metadata.density, channels: metadata.channels, hasProfile: !!metadata.icc, hasAlpha: !!metadata.hasAlpha, size: (await fs.stat(filePath)).size, }; } catch (error) { console.error("Failed to extract image metadata:", error); throw new Error("Failed to process image metadata"); } } /** * 단일 크기 이미지 처리 */ async processImageSize( inputPath: string, outputPath: string, size: ImageSize, format: "jpeg" | "png" | "webp" | "avif" = "jpeg", options: ImageProcessingOptions = {} ) { const { width, height } = IMAGE_SIZES[size]; const quality = options.quality || QUALITY_SETTINGS[size]; let pipeline = sharp(inputPath).resize(width, height, { fit: "inside", withoutEnlargement: true, }); // 메타데이터 처리 if (options.stripMetadata) { // 메타데이터 제거 (Sharp v0.32+에서는 keepMetadata: false 사용) // pipeline = pipeline.withMetadata(false); // 이 방식은 더 이상 지원되지 않음 } else if (options.preserveMetadata) { pipeline = pipeline.withMetadata(); } // 포맷별 처리 switch (format) { case "jpeg": pipeline = pipeline.jpeg({ quality, progressive: options.progressive || true, optimizeScans: options.optimize || true, }); break; case "png": pipeline = pipeline.png({ quality, progressive: options.progressive || false, }); break; case "webp": pipeline = pipeline.webp({ quality, effort: 6, }); break; case "avif": pipeline = pipeline.avif({ quality, effort: 4, }); break; } // 출력 디렉토리 생성 await fs.mkdir(path.dirname(outputPath), { recursive: true }); // 이미지 처리 및 저장 const info = await pipeline.toFile(outputPath); const stats = await fs.stat(outputPath); return { width: info.width, height: info.height, fileSize: stats.size, path: outputPath, url: outputPath.replace(this.basePath, this.baseUrl), }; } /** * 전체 이미지 처리 파이프라인 */ async processImage( inputPath: string, fileName: string, options: ImageProcessingOptions = {} ): Promise<ProcessingResult> { try { // 원본 메타데이터 추출 const originalMetadata = await this.getImageMetadata(inputPath); // 기본 설정 const sizes = options.sizes || (["thumbnail", "small", "medium", "large"] as ImageSize[]); const outputDir = options.outputDirectory || path.join(this.basePath, "processed"); // 파일명에서 확장자 제거 const nameWithoutExt = path.parse(fileName).name; const originalFormat = originalMetadata.format as SupportedFormat; // 처리 결과 저장 const processed: ProcessingResult["processed"] = []; const webp: ProcessingResult["webp"] = []; const avif: ProcessingResult["avif"] = []; // 각 크기별 처리 for (const size of sizes) { // 원본 포맷으로 처리 const processedPath = path.join( outputDir, `${nameWithoutExt}_${size}.${originalFormat}` ); const processedResult = await this.processImageSize( inputPath, processedPath, size, originalFormat === "png" ? "png" : "jpeg", options ); processed.push({ size, format: originalFormat, ...processedResult, }); // WebP 변환 if (options.generateWebP) { const webpPath = path.join( outputDir, `${nameWithoutExt}_${size}.webp` ); const webpResult = await this.processImageSize( inputPath, webpPath, size, "webp", options ); webp.push({ size, ...webpResult, }); } // AVIF 변환 if (options.generateAVIF) { const avifPath = path.join( outputDir, `${nameWithoutExt}_${size}.avif` ); const avifResult = await this.processImageSize( inputPath, avifPath, size, "avif", options ); avif.push({ size, ...avifResult, }); } } return { original: { width: originalMetadata.width, height: originalMetadata.height, format: originalMetadata.format, size: originalMetadata.size, path: inputPath, }, processed, ...(webp.length > 0 && { webp }), ...(avif.length > 0 && { avif }), metadata: { colorSpace: originalMetadata.colorSpace, density: originalMetadata.density, channels: originalMetadata.channels, hasProfile: originalMetadata.hasProfile, hasAlpha: originalMetadata.hasAlpha, }, }; } catch (error) { console.error("Image processing failed:", error); throw new Error( `Failed to process image: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } /** * 반응형 이미지 srcset 생성 */ generateSrcSet(processedImages: ProcessingResult["processed"]): string { return processedImages.map((img) => `${img.url} ${img.width}w`).join(", "); } /** * WebP srcset 생성 */ generateWebPSrcSet(webpImages: ProcessingResult["webp"]): string { if (!webpImages) return ""; return webpImages.map((img) => `${img.url} ${img.width}w`).join(", "); } /** * Picture 태그용 sources 생성 */ generatePictureSources(result: ProcessingResult) { const sources = []; // AVIF 소스 if (result.avif) { sources.push({ type: "image/avif", srcset: result.avif.map((img) => `${img.url} ${img.width}w`).join(", "), }); } // WebP 소스 if (result.webp) { sources.push({ type: "image/webp", srcset: result.webp.map((img) => `${img.url} ${img.width}w`).join(", "), }); } // 기본 포맷 소스 sources.push({ type: `image/${result.original.format}`, srcset: this.generateSrcSet(result.processed), }); return sources; } /** * 이미지 최적화 통계 */ calculateOptimizationStats(result: ProcessingResult) { const originalSize = result.original.size; const processedSizes = result.processed.map((p) => p.fileSize); const webpSizes = result.webp?.map((p) => p.fileSize) || []; const avifSizes = result.avif?.map((p) => p.fileSize) || []; const totalProcessed = processedSizes.reduce((a, b) => a + b, 0); const totalWebP = webpSizes.reduce((a, b) => a + b, 0); const totalAVIF = avifSizes.reduce((a, b) => a + b, 0); return { original: originalSize, processed: totalProcessed, webp: totalWebP, avif: totalAVIF, savings: { processed: ((originalSize - totalProcessed) / originalSize) * 100, webp: totalWebP > 0 ? ((totalProcessed - totalWebP) / totalProcessed) * 100 : 0, avif: totalAVIF > 0 ? ((totalProcessed - totalAVIF) / totalProcessed) * 100 : 0, }, }; } } // 기본 이미지 프로세서 인스턴스 export const imageProcessor = new ImageProcessor(); // 유틸리티 함수들 export function isImageFile(mimeType: string): boolean { return ( mimeType.startsWith("image/") && SUPPORTED_FORMATS.some((format) => mimeType.includes(format)) ); } export function getOptimalFormat(userAgent?: string): "webp" | "avif" | "jpeg" { if (!userAgent) return "jpeg"; // AVIF 지원 확인 (Chrome 85+, Firefox 93+) if ( userAgent.includes("Chrome/") && parseInt(userAgent.match(/Chrome\/(\d+)/)?.[1] || "0") >= 85 ) { return "avif"; } // WebP 지원 확인 (대부분의 모던 브라우저) if ( userAgent.includes("Chrome/") || userAgent.includes("Firefox/") || userAgent.includes("Edge/") ) { return "webp"; } return "jpeg"; } export function generateResponsiveImageHTML( result: ProcessingResult, alt: string, className?: string, sizes?: string ): string { const sources = imageProcessor.generatePictureSources(result); const fallbackImg = result.processed[result.processed.length - 1]; const sourceTags = sources .slice(0, -1) .map( (source) => `<source type="${source.type}" srcset="${source.srcset}" ${ sizes ? `sizes="${sizes}"` : "" }>` ) .join("\n "); return `<picture${className ? ` class="${className}"` : ""}> ${sourceTags} <img src="${fallbackImg.url}" srcset="${sources[sources.length - 1].srcset}" ${sizes ? `sizes="${sizes}"` : ""} alt="${alt}" loading="lazy" decoding="async"> </picture>`; }