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