@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
456 lines (409 loc) • 11.6 kB
text/typescript
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>`;
}