UNPKG

@restnfeel/agentc-starter-kit

Version:

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

425 lines (378 loc) 11.3 kB
import sharp from "sharp"; import path from "path"; export interface WatermarkOptions { text?: string; image?: string; // 워터마크 이미지 경로 position: | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "center"; opacity: number; // 0-1 사이 fontSize?: number; // 텍스트 워터마크용 fontColor?: string; // 텍스트 워터마크용 margin?: number; // 가장자리로부터의 여백 (픽셀) scale?: number; // 워터마크 크기 비율 (0-1 사이) } export interface WatermarkResult { success: boolean; outputPath?: string; error?: string; metadata?: { width: number; height: number; format: string; size: number; }; } export class WatermarkService { private defaultOptions: Partial<WatermarkOptions> = { position: "bottom-right", opacity: 0.5, fontSize: 24, fontColor: "#ffffff", margin: 20, scale: 0.1, }; /** * 이미지에 워터마크 적용 */ async applyWatermark( inputPath: string, outputPath: string, options: WatermarkOptions ): Promise<WatermarkResult> { try { const mergedOptions = { ...this.defaultOptions, ...options }; // 원본 이미지 정보 가져오기 const imageInfo = await sharp(inputPath).metadata(); if (!imageInfo.width || !imageInfo.height) { throw new Error("이미지 크기 정보를 가져올 수 없습니다."); } let pipeline = sharp(inputPath); if (mergedOptions.text) { // 텍스트 워터마크 적용 pipeline = await this.applyTextWatermark( pipeline, mergedOptions.text, imageInfo.width, imageInfo.height, mergedOptions ); } else if (mergedOptions.image) { // 이미지 워터마크 적용 pipeline = await this.applyImageWatermark( pipeline, mergedOptions.image, imageInfo.width, imageInfo.height, mergedOptions ); } else { throw new Error("텍스트 또는 이미지 워터마크가 필요합니다."); } // 결과 저장 await pipeline.jpeg({ quality: 90 }).toFile(outputPath); // 결과 메타데이터 가져오기 const outputInfo = await sharp(outputPath).metadata(); return { success: true, outputPath, metadata: { width: outputInfo.width || 0, height: outputInfo.height || 0, format: outputInfo.format || "unknown", size: outputInfo.size || 0, }, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "워터마크 적용 실패", }; } } /** * 텍스트 워터마크 적용 */ private async applyTextWatermark( pipeline: sharp.Sharp, text: string, imageWidth: number, imageHeight: number, options: WatermarkOptions ): Promise<sharp.Sharp> { const fontSize = options.fontSize || this.defaultOptions.fontSize || 24; const fontColor = options.fontColor || this.defaultOptions.fontColor || "#ffffff"; const margin = options.margin || this.defaultOptions.margin || 20; const opacity = Math.round((options.opacity || 0.5) * 255); // SVG로 텍스트 워터마크 생성 const textSvg = this.createTextSvg(text, fontSize, fontColor, opacity); const textBuffer = Buffer.from(textSvg); // 텍스트 이미지 생성 const textImage = await sharp(textBuffer).png().toBuffer(); const textInfo = await sharp(textImage).metadata(); if (!textInfo.width || !textInfo.height) { throw new Error("텍스트 워터마크 크기 정보를 가져올 수 없습니다."); } // 위치 계산 const position = this.calculatePosition( imageWidth, imageHeight, textInfo.width, textInfo.height, options.position, margin ); // 워터마크 합성 return pipeline.composite([ { input: textImage, top: position.top, left: position.left, blend: "over", }, ]); } /** * 이미지 워터마크 적용 */ private async applyImageWatermark( pipeline: sharp.Sharp, watermarkPath: string, imageWidth: number, imageHeight: number, options: WatermarkOptions ): Promise<sharp.Sharp> { const margin = options.margin || this.defaultOptions.margin || 20; const scale = options.scale || this.defaultOptions.scale || 0.1; const opacity = options.opacity || 0.5; // 워터마크 이미지 크기 조정 const watermarkSize = Math.min(imageWidth, imageHeight) * scale; const watermarkImage = await sharp(watermarkPath) .resize({ width: Math.round(watermarkSize), height: Math.round(watermarkSize), fit: "inside", withoutEnlargement: true, }) .png() .toBuffer(); // 투명도 적용 const watermarkWithOpacity = await sharp(watermarkImage) .composite([ { input: { create: { width: Math.round(watermarkSize), height: Math.round(watermarkSize), channels: 4, background: { r: 255, g: 255, b: 255, alpha: opacity }, }, }, blend: "dest-in", }, ]) .png() .toBuffer(); const watermarkInfo = await sharp(watermarkWithOpacity).metadata(); if (!watermarkInfo.width || !watermarkInfo.height) { throw new Error("워터마크 이미지 크기 정보를 가져올 수 없습니다."); } // 위치 계산 const position = this.calculatePosition( imageWidth, imageHeight, watermarkInfo.width, watermarkInfo.height, options.position, margin ); // 워터마크 합성 return pipeline.composite([ { input: watermarkWithOpacity, top: position.top, left: position.left, blend: "over", }, ]); } /** * 텍스트 SVG 생성 */ private createTextSvg( text: string, fontSize: number, fontColor: string, opacity: number ): string { const textWidth = text.length * fontSize * 0.6; // 대략적인 텍스트 너비 const textHeight = fontSize * 1.2; return ` <svg width="${textWidth}" height="${textHeight}" xmlns="http://www.w3.org/2000/svg"> <text x="0" y="${fontSize}" font-family="Arial, sans-serif" font-size="${fontSize}" fill="${fontColor}" fill-opacity="${opacity / 255}" font-weight="bold" >${text}</text> </svg> `; } /** * 워터마크 위치 계산 */ private calculatePosition( imageWidth: number, imageHeight: number, watermarkWidth: number, watermarkHeight: number, position: WatermarkOptions["position"], margin: number ): { top: number; left: number } { switch (position) { case "top-left": return { top: margin, left: margin }; case "top-right": return { top: margin, left: imageWidth - watermarkWidth - margin }; case "bottom-left": return { top: imageHeight - watermarkHeight - margin, left: margin }; case "bottom-right": return { top: imageHeight - watermarkHeight - margin, left: imageWidth - watermarkWidth - margin, }; case "center": return { top: Math.round((imageHeight - watermarkHeight) / 2), left: Math.round((imageWidth - watermarkWidth) / 2), }; default: return { top: margin, left: margin }; } } /** * 배치 워터마킹 */ async applyWatermarkBatch( inputPaths: string[], outputDir: string, options: WatermarkOptions, onProgress?: (completed: number, total: number, currentFile: string) => void ): Promise<{ successful: string[]; failed: Array<{ path: string; error: string }>; }> { const successful: string[] = []; const failed: Array<{ path: string; error: string }> = []; for (let i = 0; i < inputPaths.length; i++) { const inputPath = inputPaths[i]; const fileName = path.basename(inputPath, path.extname(inputPath)); const outputPath = path.join(outputDir, `${fileName}_watermarked.jpg`); if (onProgress) { onProgress(i, inputPaths.length, inputPath); } const result = await this.applyWatermark(inputPath, outputPath, options); if (result.success) { successful.push(outputPath); } else { failed.push({ path: inputPath, error: result.error || "Unknown error", }); } } if (onProgress) { onProgress(inputPaths.length, inputPaths.length, "완료"); } return { successful, failed }; } /** * 워터마크 제거 (원본 복원) */ async removeWatermark( watermarkedPath: string, originalPath: string ): Promise<boolean> { try { // 실제로는 원본 파일을 복사하거나 데이터베이스에서 원본 경로를 찾아 복원 // 여기서는 간단히 파일 복사로 구현 await sharp(originalPath).toFile(watermarkedPath); return true; } catch { return false; } } /** * 워터마크 설정 검증 */ validateOptions(options: WatermarkOptions): { valid: boolean; errors: string[]; } { const errors: string[] = []; if (!options.text && !options.image) { errors.push("텍스트 또는 이미지 워터마크가 필요합니다."); } if (options.opacity < 0 || options.opacity > 1) { errors.push("투명도는 0과 1 사이의 값이어야 합니다."); } if (options.fontSize && options.fontSize < 1) { errors.push("폰트 크기는 1보다 커야 합니다."); } if (options.scale && (options.scale < 0 || options.scale > 1)) { errors.push("크기 비율은 0과 1 사이의 값이어야 합니다."); } if (options.margin && options.margin < 0) { errors.push("여백은 0보다 커야 합니다."); } return { valid: errors.length === 0, errors, }; } } // 싱글톤 인스턴스 let watermarkServiceInstance: WatermarkService | null = null; export function getWatermarkService(): WatermarkService { if (!watermarkServiceInstance) { watermarkServiceInstance = new WatermarkService(); } return watermarkServiceInstance; } // 편의 함수들 export async function addTextWatermark( inputPath: string, outputPath: string, text: string, options?: Partial<WatermarkOptions> ): Promise<WatermarkResult> { const service = getWatermarkService(); return service.applyWatermark(inputPath, outputPath, { text, position: "bottom-right", opacity: 0.5, ...options, }); } export async function addImageWatermark( inputPath: string, outputPath: string, watermarkImagePath: string, options?: Partial<WatermarkOptions> ): Promise<WatermarkResult> { const service = getWatermarkService(); return service.applyWatermark(inputPath, outputPath, { image: watermarkImagePath, position: "bottom-right", opacity: 0.5, scale: 0.1, ...options, }); }