castad-pre-v0.3/castad-data/server/geminiBackendService.js

1217 lines
48 KiB
JavaScript

const { GoogleGenAI, Type, Modality } = require('@google/genai');
const GEMINI_API_KEY = process.env.VITE_GEMINI_API_KEY; // .env에서 API 키 로드
// ============================================
// API 사용량 추적 유틸리티
// ============================================
let db = null;
try {
db = require('./db');
} catch (e) {
console.warn('DB not available for API usage logging');
}
/**
* API 사용량 로깅
*/
const logApiUsage = async (data) => {
if (!db) return;
const {
service = 'gemini',
model = 'unknown',
endpoint = '',
userId = null,
tokensInput = 0,
tokensOutput = 0,
imageCount = 0,
audioSeconds = 0,
videoSeconds = 0,
status = 'success',
errorMessage = null,
latencyMs = 0,
costEstimate = 0,
metadata = null
} = data;
return new Promise((resolve) => {
db.run(
`INSERT INTO api_usage_logs
(service, model, endpoint, user_id, tokens_input, tokens_output, image_count,
audio_seconds, video_seconds, status, error_message, latency_ms, cost_estimate, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[service, model, endpoint, userId, tokensInput, tokensOutput, imageCount,
audioSeconds, videoSeconds, status, errorMessage, latencyMs, costEstimate,
metadata ? JSON.stringify(metadata) : null],
(err) => {
if (err) console.error('API usage log error:', err);
resolve();
}
);
});
};
// ============================================
// 이미지 생성 모델 우선순위 시스템
// ============================================
const IMAGE_MODELS = [
{
id: 'gemini-2.0-flash-preview-image-generation',
name: 'Gemini 2.0 Flash Image (Preview)',
priority: 1,
costPerImage: 0.02, // 예상 비용 (USD)
maxRetries: 2
},
{
id: 'gemini-2.5-flash-image',
name: 'Gemini 2.5 Flash Image',
priority: 2,
costPerImage: 0.015,
maxRetries: 2
},
{
id: 'imagen-3.0-generate-002',
name: 'Imagen 3',
priority: 3,
costPerImage: 0.03,
maxRetries: 1
}
];
/**
* 이미지 생성을 여러 모델로 시도 (우선순위 기반 폴백)
*/
const generateImageWithFallback = async (ai, prompt, imageParts = [], options = {}) => {
const { aspectRatio = '16:9', userId = null } = options;
const errors = [];
for (const model of IMAGE_MODELS) {
const startTime = Date.now();
for (let retry = 0; retry < model.maxRetries; retry++) {
try {
console.log(`[Image Gen] Trying ${model.name} (attempt ${retry + 1}/${model.maxRetries})`);
let response;
if (model.id.startsWith('imagen')) {
// Imagen 모델용 API
response = await ai.models.generateImages({
model: model.id,
prompt: prompt,
config: {
numberOfImages: 1,
aspectRatio: aspectRatio
}
});
const imageData = response.generatedImages?.[0];
if (imageData?.image?.imageBytes) {
const latency = Date.now() - startTime;
await logApiUsage({
service: 'gemini',
model: model.id,
endpoint: 'generateImages',
userId,
imageCount: 1,
latencyMs: latency,
costEstimate: model.costPerImage,
metadata: { aspectRatio, prompt: prompt.substring(0, 100) }
});
return {
base64: imageData.image.imageBytes,
mimeType: 'image/png',
modelUsed: model.name
};
}
} else {
// Gemini 이미지 모델용 API
response = await ai.models.generateContent({
model: model.id,
contents: {
parts: [
{ text: prompt },
...imageParts
]
},
config: {
responseModalities: [Modality.IMAGE],
}
});
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) {
const latency = Date.now() - startTime;
await logApiUsage({
service: 'gemini',
model: model.id,
endpoint: 'generateContent/image',
userId,
imageCount: 1,
latencyMs: latency,
costEstimate: model.costPerImage,
metadata: { aspectRatio, prompt: prompt.substring(0, 100) }
});
return {
base64: part.inlineData.data,
mimeType: part.inlineData.mimeType || 'image/png',
modelUsed: model.name
};
}
}
}
throw new Error('No image data in response');
} catch (error) {
const latency = Date.now() - startTime;
const errorMsg = error.message || 'Unknown error';
errors.push({ model: model.name, error: errorMsg, attempt: retry + 1 });
console.warn(`[Image Gen] ${model.name} failed (attempt ${retry + 1}): ${errorMsg}`);
// 로깅 (에러)
await logApiUsage({
service: 'gemini',
model: model.id,
endpoint: 'generateContent/image',
userId,
status: 'error',
errorMessage: errorMsg,
latencyMs: latency
});
// 429 (Rate Limit) 또는 500 에러면 다음 모델로
if (error.status === 429 || error.status === 500 ||
errorMsg.includes('quota') || errorMsg.includes('rate') ||
errorMsg.includes('Internal error')) {
console.log(`[Image Gen] Quota/rate limit hit, trying next model...`);
break; // 다음 모델로
}
// 다른 에러는 재시도
if (retry < model.maxRetries - 1) {
await new Promise(r => setTimeout(r, 1000 * (retry + 1))); // 백오프
}
}
}
}
// 모든 모델 실패
const errorSummary = errors.map(e => `${e.model}: ${e.error}`).join('; ');
throw new Error(`모든 이미지 생성 모델 실패: ${errorSummary}`);
};
const getVoiceName = (config) => {
if (config.gender === 'Female') {
if (config.tone === 'Bright' || config.tone === 'Energetic') return 'Zephyr';
return 'Kore';
} else {
if (config.tone === 'Professional' || config.tone === 'Calm') return 'Charon';
if (config.tone === 'Energetic') return 'Fenrir';
return 'Puck';
}
};
/**
* 서버 사이드에서 창의적 콘텐츠 생성 (광고 카피 & 가사/스크립트) - Gemini 2.0 Flash
* 업체의 이미지와 정보를 분석하여 마케팅에 최적화된 문구를 생성합니다.
*
* @param {object} info - 비즈니스 정보 객체 (BusinessInfo와 유사)
* @param {Array<object>} info.images - Base64 인코딩된 이미지 데이터 배열 ({ mimeType, base64 })
* @param {string} info.name - 브랜드 이름
* @param {string} info.description - 브랜드 설명
* @param {string} info.audioMode - 오디오 모드
* @param {string} info.musicGenre - 음악 장르
* @param {string} info.musicDuration - 음악 길이
* @param {object} info.ttsConfig - TTS 설정
* @param {string} info.language - 콘텐츠 생성 언어
* @param {Array<string>} info.pensionCategories - 펜션 카테고리 배열
* @returns {Promise<{adCopy: string[], lyrics: string}>}
*/
const generateCreativeContent = async (info) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const imageParts = info.images.map((imageData) => ({
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
}));
const lyricStructureInstruction = info.musicDuration === 'Short'
? `
- **길이 제약사항**: 노래는 반드시 30초 이내여야 함.
- **구조**: [Verse 1] -> [Chorus] -> [Outro] 만 포함할 것.
- **줄 수**: 총 8줄 이내. 짧고 리듬감 있게 작성.
`
: `
- **길이**: 전체 길이 곡 (약 2분).
- **구조**: [Verse 1] -> [Chorus] -> [Verse 2] -> [Chorus] -> [Outro].
`;
const langMap = {
'KO': '한국어 (Korean)',
'EN': '영어 (English)',
'JA': '일본어 (Japanese)',
'ZH': '중국어 (Chinese, Simplified)',
'TH': '태국어 (Thai)',
'VI': '베트남어 (Vietnamese)'
};
const targetLang = langMap[info.language] || '한국어 (Korean)';
// 펜션 카테고리 매핑 (한국어 설명)
const categoryMap = {
'PoolVilla': '풀빌라 (프라이빗 수영장)',
'OceanView': '오션뷰 (바다 전망)',
'Mountain': '산장/계곡 (자연 속 힐링)',
'Private': '독채 (프라이빗 공간)',
'Couple': '커플펜션 (로맨틱)',
'Family': '가족펜션 (단체 숙박)',
'Pet': '애견동반 (반려동물 환영)',
'Glamping': '글램핑 (캠핑+럭셔리)',
'Traditional': '한옥펜션 (전통미)'
};
// 선택된 카테고리를 텍스트로 변환
const pensionTypesText = info.pensionCategories && info.pensionCategories.length > 0
? info.pensionCategories.map(cat => categoryMap[cat] || cat).join(', ')
: '일반 펜션';
// 근처 축제 정보 처리
let festivalContext = '';
if (info.nearbyFestivals && info.nearbyFestivals.length > 0) {
const festivalList = info.nearbyFestivals.map(f => {
let dateStr = '';
if (f.eventstartdate) {
const start = `${f.eventstartdate.slice(0, 4)}.${f.eventstartdate.slice(4, 6)}.${f.eventstartdate.slice(6, 8)}`;
const end = f.eventenddate ? `${f.eventenddate.slice(0, 4)}.${f.eventenddate.slice(4, 6)}.${f.eventenddate.slice(6, 8)}` : '';
dateStr = ` (${start}~${end})`;
}
return `- ${f.title}${dateStr}${f.addr1 ? ` @ ${f.addr1}` : ''}`;
}).join('\n');
festivalContext = `
**근처 축제 정보** (콘텐츠에 자연스럽게 연동하라):
${festivalList}
- 이 축제들을 언급하며 "축제와 함께하는 특별한 펜션 여행"이라는 메시지를 전달하라.
- 축제 기간에 맞춰 방문할 수 있는 특별한 경험을 강조하라.
`;
}
const prompt = `
역할: 전문 ${targetLang} 카피라이터 및 작사가.
클라이언트: ${info.name}
컨텍스트: ${info.description}
펜션 유형: ${pensionTypesText} - 이 특성을 마케팅 콘텐츠에 반영하세요.
모드: ${info.audioMode} (Song=노래, Narration=내레이션)
스타일: ${info.audioMode === 'Song' ? info.musicGenre : info.ttsConfig.tone}
언어: **${targetLang}** 로 작성 필수.
${festivalContext}
과제 1: 임팩트 있는 **${targetLang}** 광고 헤드라인 4개를 작성하라.
- **완벽한 ${targetLang} 사용**: 자연스럽고 세련된 현지 마케팅 표현 사용.
- **펜션 유형 반영**: ${pensionTypesText}의 특징(프라이빗, 로맨틱, 자연, 럭셔리 등)을 강조하라.
- **형식**: 짧고 강렬한 "빌보드" 스타일.
- **줄바꿈**: 시각적 균형을 위해 문장의 중간에 적절한 줄바꿈(\n)을 반드시 넣어라.
과제 2: ${info.audioMode === 'Song' ? '노래 가사' : '내레이션 스크립트'} 작성.
- 언어: **${targetLang}**.
- **펜션 컨셉 활용**: ${pensionTypesText}의 매력(예: 풀빌라면 프라이빗한 휴식, 오션뷰면 파도소리와 일몰)을 자연스럽게 녹여내라.
${info.audioMode === 'Song'
? `- **필수 포맷**: 반드시 다음 헤더를 사용해야 함: "[Verse 1]", "[Chorus]", 등.
${lyricStructureInstruction}
- 내용: ${info.musicGenre} 장르에 맞는 감성적이고 스토리텔링이 있는 가사.
`
: `- 구조: 상업 광고용 30초 분량의 매력적인 내레이션 스크립트.
- 톤앤매너: ${info.ttsConfig.tone}. 자연스러운 구어체 ${targetLang}.`}
출력 형식 (JSON):
{
"adCopy": ["헤드라인 1\n(줄바꿈포함)", "헤드라인 2\n(줄바꿈포함)", ...],
"lyrics": "Verse/Chorus 헤더가 포함된 전체 가사 또는 스크립트 텍스트..."
}
`;
const response = await ai.models.generateContent({
model: 'gemini-2.0-flash',
contents: {
parts: [
{ text: prompt },
...imageParts
]
},
config: {
temperature: 0.7,
responseMimeType: 'application/json',
responseSchema: {
type: Type.OBJECT,
properties: {
adCopy: {
type: Type.ARRAY,
items: { type: Type.STRING }
},
lyrics: { type: Type.STRING }
}
}
}
});
if (response.text) {
try {
return JSON.parse(response.text);
} catch (e) {
console.error("JSON 파싱 오류", response.text);
throw new Error("창의적 콘텐츠 파싱에 실패했습니다.");
}
}
throw new Error("텍스트 콘텐츠 생성 실패");
};
/**
* 서버 사이드에서 고급 음성 합성 (TTS) - Gemini 2.5 Flash TTS
* 텍스트를 입력받아 자연스러운 AI 성우 목소리(Base64)를 생성합니다.
*
* @param {string} text - 음성으로 변환할 텍스트
* @param {object} config - TTS 설정 (gender, age, tone)
* @returns {Promise<string>} - Base64 인코딩된 오디오 데이터
*/
const generateAdvancedSpeech = async (
text,
config
) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const voiceName = getVoiceName(config);
const cleanText = text
.replace(/\[.*?\]/g, '')
.replace(/\(.*?\)/g, '')
.replace(/<.*?>/g, '')
.replace(/(Narrator|나레이터|성우|Speaker|Woman|Man).*?:/gi, '')
.replace(/\*\*/g, '')
.replace(/[•*-]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
// console.log(`음성 생성 중: 목소리=${voiceName}, 텍스트=${cleanText.substring(0, 50)}...`); // 디버그 로그 제거
const response = await ai.models.generateContent({
model: "gemini-2.5-flash-preview-tts",
contents: [{ parts: [{ text: cleanText }] }],
config: {
responseModalities: [Modality.AUDIO],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName }
}
}
},
});
const base64Audio = response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
if (!base64Audio) {
throw new Error("Gemini TTS로부터 오디오 데이터를 받지 못했습니다.");
}
return base64Audio;
};
/**
* 서버 사이드에서 광고 포스터 생성 - 모델 우선순위 기반 폴백 시스템
* 업로드된 이미지들을 합성하고 브랜드 분위기에 맞는 고화질 포스터를 생성합니다.
*
* @param {object} info - 비즈니스 정보 객체 (BusinessInfo와 유사)
* @param {Array<object>} info.images - Base64 인코딩된 이미지 데이터 배열 ({ mimeType, base64 })
* @param {string} info.name - 브랜드 이름
* @param {string} info.description - 브랜드 설명
* @param {string} info.aspectRatio - 화면 비율
* @param {boolean} info.useAiImages - AI 이미지 생성 허용 여부
* @param {number} info.userId - 사용자 ID (API 로깅용)
* @returns {Promise<{ base64: string; mimeType: string; modelUsed?: string }>}
*/
const generateAdPoster = async (info) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const imageParts = info.images.map((imageData) => ({
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
}));
let prompt = '';
if (imageParts.length > 0) {
if (info.useAiImages) {
prompt = `
역할: 전문 아트 디렉터.
과제: 브랜드 "${info.name}"를 위한 단 하나의, 조화롭고 수상 경력에 빛나는 광고 포스터를 제작하라.
지침:
1. **시각적 합성(Composite Visuals)**: 제공된 여러 참조 이미지를 바탕으로 예술적으로 재창조하라. 필요하다면 요소를 추가하거나 조명을 개선하여 퀄리티를 높여라.
2. 테마: ${info.description}.
3. 스타일: 시네마틱 조명, 4k 해상도, 하이엔드 상업 사진.
4. 종횡비: ${info.aspectRatio === '9:16' ? '9:16 세로 모드' : '16:9 가로 모드'}.
`;
} else {
prompt = `
역할: 사진 편집자.
과제: 제공된 이미지들을 사용하여 하나의 깔끔한 홍보 이미지를 만들어라.
**매우 중요한 제약사항 (Strict Constraints)**:
1. **새로운 사물 생성 금지**: 제공된 이미지에 없는 물체, 사람, 배경을 절대로 새로 그려넣지 마라.
2. **원본 유지**: 제공된 이미지들의 톤과 느낌을 최대한 유지하며 자연스럽게 합성하라. (Digital Collage).
3. **사실성**: 과도한 AI 효과나 비현실적인 변형을 피하라.
4. 종횡비: ${info.aspectRatio === '9:16' ? '9:16 세로 모드' : '16:9 가로 모드'}.
`;
}
} else {
if (!info.useAiImages) {
throw new Error("이미지가 없고 AI 이미지 생성 옵션도 꺼져 있어 포스터를 만들 수 없습니다. 사진을 업로드하거나 옵션을 켜주세요.");
}
prompt = `
역할: 세계적인 상업 사진작가 및 아트 디렉터.
과제: 브랜드 "${info.name}"를 홍보하기 위한 최고급 상업 광고 사진을 생성하라.
브랜드 설명: "${info.description}"
지침:
1. **이미지 생성**: 위 브랜드 설명에 완벽하게 부합하는, 디테일이 살아있는 고해상도 사진을 창조하라. 실제 매장이나 제품을 촬영한 것 같은 사실감을 주어라.
2. 스타일:
- 조명: 드라마틱하고 따뜻한 시네마틱 조명.
- 퀄리티: 8k UHD, 초고화질, 잡지 커버 수준의 디테일.
- 분위기: 고객이 방문하고 싶게 만드는 매력적이고 환영하는 분위기.
3. 구도: ${info.aspectRatio === '9:16' ? '9:16 세로 비율 (Vertical/Portrait) 필수. 스마트폰 전체 화면용. 가로 사진을 90도 회전시키지 마라.' : '16:9 와이드 비율 (Horizontal). 중앙이나 한쪽에 텍스트를 배치할 수 있는 여백을 고려한 구도.'}
4. 금지: 흐릿하거나 왜곡된 이미지, 부자연스러운 텍스트 생성 금지.
이 이미지는 뮤직 비디오의 메인 배경으로 사용될 것이다.
`;
}
try {
// 우선순위 기반 모델 폴백 시스템 사용
const result = await generateImageWithFallback(ai, prompt, imageParts, {
aspectRatio: info.aspectRatio || '16:9',
userId: info.userId
});
console.log(`[Ad Poster] Generated successfully using: ${result.modelUsed}`);
return result;
} catch (error) {
console.error('[Ad Poster] All models failed:', error.message);
// 최종 폴백: 원본 이미지 반환
if (imageParts.length > 0 && info.images[0]) {
console.warn("[Ad Poster] Falling back to original image");
await logApiUsage({
service: 'gemini',
model: 'fallback-original',
endpoint: 'generateAdPoster',
userId: info.userId,
status: 'fallback',
errorMessage: error.message
});
return {
base64: info.images[0].base64,
mimeType: info.images[0].mimeType,
modelUsed: 'Original Image (Fallback)'
};
}
throw error;
}
};
/**
* 서버 사이드에서 다수의 비즈니스 관련 이미지 생성 (갤러리/슬라이드쇼용)
* @param {object} info - 비즈니스 정보 객체
* @param {number} count - 생성할 이미지 개수
* @returns {Promise<string[]>} - Base64 Data URL 배열
*/
const generateImageGallery = async (info, count) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
// gemini-2.5-flash-image: production-ready, stable image generation model
const model = 'gemini-2.5-flash-image';
const perspectives = [
"Wide angle shot of the interior, welcoming atmosphere, cinematic lighting",
"Close-up detail shot of the main product or service, high resolution, macro photography",
"Exterior view of the storefront or location, inviting and stylish",
"Candid shot of happy customers enjoying the service or atmosphere (soft focus background)",
"Artistic composition of the brand elements or menu items, magazine style"
];
const tasks = Array.from({ length: count }).map(async (_, i) => {
const perspective = perspectives[i % perspectives.length];
const prompt = `
Role: Professional Photographer.
Subject: ${info.name} - ${info.description}
Shot Type: ${perspective}
Style: 4k, Photorealistic, Commercial Advertisement Standard, ${info.textEffect} vibe.
Aspect Ratio: 16:9.
`;
try {
const response = await ai.models.generateContent({
model,
contents: { parts: [{ text: prompt }] },
config: {
responseModalities: [Modality.IMAGE],
}
});
const part = response.candidates?.[0]?.content?.parts?.[0];
if (part && part.inlineData) {
return `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`;
}
return null;
} catch (e) {
console.error(`Image generation failed for index ${i}`, e);
return null;
}
});
const results = await Promise.all(tasks);
return results.filter((img) => img !== null);
};
/**
* 서버 사이드에서 비디오 배경 생성 - Veo (Video Generation Model)
* 생성된 포스터 이미지를 기반으로 움직이는 시네마틱 배경 영상을 만듭니다.
*
* @param {string} posterBase64 - Base64 인코딩된 포스터 이미지 데이터
* @param {string} posterMimeType - 포스터 이미지 MIME 타입
* @param {string} aspectRatio - 비디오 화면 비율
* @returns {Promise<string>} - 생성된 비디오의 원격 URL
*/
const generateVideoBackground = async (
posterBase64,
posterMimeType,
aspectRatio = '16:9'
) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
let operation = await ai.models.generateVideos({
model: 'veo-3.1-fast-generate-preview',
prompt: '시네마틱한 움직임, 상업 광고 영상미, 4k, 느리고 부드러운 움직임, 감성적, 추상적이고 미니멀한 모션 그래픽 스타일',
image: {
imageBytes: posterBase64,
mimeType: posterMimeType,
},
config: {
numberOfVideos: 1,
resolution: '720p',
aspectRatio: aspectRatio
}
});
while (!operation.done) {
await new Promise(resolve => setTimeout(resolve, 5000));
operation = await ai.operations.getVideosOperation({ operation: operation });
}
if (operation.error) {
console.error("Veo 생성 오류:", operation.error);
throw new Error(`비디오 생성 실패: ${operation.error.message || "알 수 없는 오류"}`);
}
let downloadLink = operation.response?.generatedVideos?.[0]?.video?.uri;
if (!downloadLink) {
const anyOp = operation;
downloadLink = anyOp.result?.generatedVideos?.[0]?.video?.uri;
}
if (!downloadLink) {
console.error("작업 내 다운로드 링크 누락. 응답 덤프:", JSON.stringify(operation, null, 2));
throw new Error("비디오 생성이 완료되었으나 URI가 반환되지 않았습니다. 안전 필터에 의해 콘텐츠가 차단되었을 수 있습니다. 다른 이미지나 설명으로 시도해보세요.");
}
// API 키를 직접 노출하지 않으므로, downloadLink만 반환
return downloadLink;
};
/**
* 서버 사이드에서 AI 이미지 검수 (Gemini Vision)
* 업로드되거나 크롤링된 이미지 중 마케팅에 적합한 사진만 선별합니다.
*
* @param {Array<object>} imagesData - Base64 인코딩된 이미지 데이터 배열 ({ mimeType, base64 })
* @returns {Promise<Array<object>>} - 선별된 Base64 이미지 데이터 배열
*/
const filterBestImages = async (imagesData) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
if (imagesData.length === 0) {
// console.warn("검수할 유효한 이미지(Base64)가 없습니다."); // 디버그 로그 제거
return [];
}
// console.log(`AI 이미지 검수 시작: 총 ${imagesData.length}장 분석 중...`); // 디버그 로그 제거
const imageParts = imagesData.map((imageData) => ({
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
}));
const prompt = `
역할: 엄격한 상업 사진 편집장.
작업: 다음 이미지들을 분석하여 "마케팅 비디오"에 사용할 수 있는 **최고의 사진**만 골라내라.
**엄격한 제외 기준 (무조건 탈락)**:
1. **사람 얼굴**: 정면이든 측면이든 식별 가능한 사람 얼굴이 포함된 경우 (초상권 보호).
2. **지저분함**: 먹다 남은 음식, 빈 그릇, 쓰레기, 지저분한 테이블.
3. **무의미한 문서**: 영수증, 택배 송장, 흐릿한 A4 용지 문서. (단, **메뉴판은 허용**).
4. **저품질**: 심하게 흔들림, 너무 어두움, 해상도 낮음.
**선정 기준 (우선 순위)**:
1. 맛있어 보이는 음식 클로즈업.
2. 분위기 있는 매장 인테리어 또는 익스테리어.
3. **정보가 담긴 메뉴판**: 고객에게 메뉴와 가격을 알려주는 깔끔한 메뉴판 사진은 **반드시 포함**하라.
4. 구도가 잡힌 감성적인 사진.
출력 형식:
오직 선정된 이미지의 **인덱스 번호(0부터 시작)**만 포함된 JSON 배열.
예시: [0, 2, 5]
(만약 모든 사진이 부적합하다면 빈 배열 [] 반환)
`;
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: {
parts: [
{ text: prompt },
...imageParts
]
},
config: {
responseMimeType: 'application/json',
temperature: 0.1
}
});
if (response.text) {
const indices = JSON.parse(response.text);
// console.log(`AI 검수 결과: ${indices.length}장 선정됨 (인덱스: ${indices.join(', ')})`); // 디버그 로그 제거
return indices
.filter(i => i >= 0 && i < imagesData.length)
.map(i => imagesData[i]);
}
} catch (e) {
console.error("AI 이미지 검수 실패:", e);
return imagesData; // 실패 시 안전하게 모든 이미지 반환
}
return imagesData;
};
/**
* 서버 사이드에서 리뷰 기반 마케팅 설명 생성
* @param {string} name - 업체명
* @param {string} rawDescription - 기본 설명
* @param {string[]} reviews - 고객 리뷰 배열
* @param {number} rating - 평균 별점
* @returns {Promise<string>} - 생성된 설명
*/
const enrichDescriptionWithReviews = async (
name,
rawDescription,
reviews,
rating
) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const prompt = `
역할: 전문 마케팅 카피라이터.
업체명: ${name}
기본 정보: ${rawDescription}
평균 별점: ${rating}
실제 고객 리뷰 요약:
${reviews.map(r => `- ${r}`).join('\n')}
지침:
위 정보를 바탕으로 뮤직 비디오 제작을 위한 **매력적이고 풍부한 업체 설명(Description)**을 한 단락으로 작성하라.
요구사항:
1. 실제 고객의 목소리(리뷰)를 자연스럽게 녹여내라.
2. 별점이 높다면(4.5 이상) 이를 강조하라.
3. 분위기, 맛, 서비스의 특징을 감성적으로 묘사하라.
4. 길이는 3~4문장 정도로 요약하라.
5. 한국어로 작성하라.
`;
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: [{ parts: [{ text: prompt }] }]
});
return response.text?.trim() || rawDescription;
};
/**
* 서버 사이드에서 이미지에서 텍스트 스타일(CSS) 추출 - Gemini 1.5 Flash
* @param {object} imageFile - Base64 인코딩된 이미지 데이터 ({ mimeType, base64 })
* @returns {Promise<string>} - 생성된 CSS 코드
*/
const extractTextEffectFromImage = async (
imageFile
) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const imageBase64 = imageFile.base64;
const mimeType = imageFile.mimeType;
const prompt = `
역할: 전문 프론트엔드 개발자 및 UI 디자이너.
작업: 제공된 이미지에 있는 **메인 텍스트의 스타일**을 분석하여 똑같이 재현할 수 있는 **CSS 코드**를 작성하라.
분석 항목:
1. **색상 (Color)**: 텍스트 색상 및 그라디언트.
2. **폰트 (Font)**: 폰트 두께(weight), 스타일(italic 등), 자간(letter-spacing). (폰트 패밀리는 일반적인 sans-serif나 serif 사용)
3. **효과 (Effects)**:
- text-shadow (네온, 그림자, 외곽선 등)
- background (배경색, 반투명 등)
- transform (기울임, 회전 등)
4. **애니메이션 (Animation)**: 이미지의 분위기에 어울리는 적절한 @keyframes 애니메이션을 하나 추가하라. (예: 부드러운 등장, 반짝임, 흔들림 등)
출력 형식:
- 오직 **CSS 코드만** 출력하라. 설명이나 마크다운 코드블록은 제외하라.
- 클래스 이름은 반드시 **.custom-effect** 로 정의하라.
- 애니메이션이 있다면 @keyframes도 함께 포함하라.
`;
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: {
parts: [
{ text: prompt },
{ inlineData: { mimeType: mimeType, data: imageBase64 } }
]
}
});
let css = response.text || '';
css = css.replace(/```css/g, '').replace(/```/g, '').trim();
return css;
};
/**
* YouTube SEO 최적화 메타데이터 생성 (다국어 지원)
* @param {object} params - 비즈니스 정보
* @param {string} params.businessName - 펜션 이름 (한국어)
* @param {string} params.businessNameEn - 펜션 이름 (영어)
* @param {string} params.description - 비즈니스 설명
* @param {Array<string>} params.categories - 펜션 카테고리
* @param {string} params.address - 주소
* @param {string} params.region - 지역명
* @param {string} params.targetAudience - 타깃 고객
* @param {Array<string>} params.mainStrengths - 주요 장점
* @param {Array<string>} params.nearbyAttractions - 인근 관광지
* @param {string} params.bookingUrl - 예약 링크
* @param {number} params.videoDuration - 영상 길이 (초)
* @param {string} params.seasonTheme - 계절/테마
* @param {string} params.priceRange - 가격대 설명
* @param {string} params.language - 추가 언어 코드 (KO는 기본)
* @returns {Promise<object>} - 다국어 SEO 메타데이터
*/
const generateYouTubeSEO = async (params) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const langMap = {
'KO': { name: '한국어', code: 'ko' },
'EN': { name: 'English', code: 'en' },
'JA': { name: '日本語', code: 'ja' },
'ZH': { name: '中文', code: 'zh' },
'TH': { name: 'ไทย', code: 'th' },
'VI': { name: 'Tiếng Việt', code: 'vi' }
};
const categoryMap = {
'PoolVilla': { ko: '풀빌라', en: 'Pool Villa' },
'OceanView': { ko: '오션뷰', en: 'Ocean View' },
'Mountain': { ko: '산장/계곡', en: 'Mountain Retreat' },
'Private': { ko: '독채펜션', en: 'Private Pension' },
'Couple': { ko: '커플펜션', en: 'Couple Pension' },
'Family': { ko: '가족펜션', en: 'Family Pension' },
'Pet': { ko: '애견동반', en: 'Pet-Friendly' },
'Glamping': { ko: '글램핑', en: 'Glamping' },
'Traditional': { ko: '한옥펜션', en: 'Traditional Hanok' }
};
const categoryTagsKo = params.categories?.map(cat => categoryMap[cat]?.ko || cat) || [];
const categoryTagsEn = params.categories?.map(cat => categoryMap[cat]?.en || cat) || [];
const pensionType = categoryTagsKo[0] || '펜션';
const pensionTypeEn = categoryTagsEn[0] || 'Pension';
// 지역 정보 추출
const regionKo = params.region || params.address?.split(' ')[0] || '';
const regionEn = params.regionEn || regionKo;
// 영상 길이 (분)
const videoDurationMin = Math.ceil((params.videoDuration || 60) / 60);
// 추가 언어 설정
const additionalLang = params.language && params.language !== 'KO' ? langMap[params.language] : null;
const prompt = `
너는 유튜브 여행/숙소 채널의 SEO 전문가이자 카피라이터다.
나는 "펜션 전문 웹사이트"를 운영하고 있고, 아래 입력 정보를 기반으로
특정 펜션을 소개하는 유튜브 영상을 올리려고 한다.
목표:
1) 한국어 시청자에게는 예약 전 정보를 자세하고 감성적으로 전달
2) ${additionalLang ? `${additionalLang.name} 시청자(외국인 여행객)에게는 핵심 정보와 위치/장점을 명확하게 안내` : '검색 최적화'}
3) YouTube 검색, 연관 동영상, Google 검색, AI 검색(LLM)까지 고려한 SEO 최적화
[입력 정보]
- 펜션 이름: ${params.businessName} / 영어 이름: ${params.businessNameEn || params.businessName}
- 지역/도시: ${regionKo} (영어 표기: ${regionEn})
- 펜션 타입: ${pensionType}
- 주요 타깃 고객: ${params.targetAudience || '커플, 가족'}
- 핵심 장점: ${params.mainStrengths?.join(', ') || categoryTagsKo.join(', ')}
- 인근 관광지: ${params.nearbyAttractions?.join(', ') || ''}
- 예약 링크(URL): ${params.bookingUrl || ''}
- 공식 웹사이트/브랜드: CastAD
- 영상 길이: ${videoDurationMin}
- 계절/테마: ${params.seasonTheme || '사계절'}
- 가격대: ${params.priceRange || '문의'}
- 설명: ${params.description || ''}
[출력 형식 - JSON만 출력]
{
"snippet": {
"title_ko": "70자 이내, 지역 + 펜션타입 + 강점1~2개 포함. 예: '${regionKo} ${pensionType} | 전 객실 개별바비큐 & 야외수영장'",
"title_${additionalLang?.code || 'en'}": "70자 이내 ${additionalLang?.name || 'English'} 제목",
"description_ko": "유튜브 본문 전체 (500~1500자, 아래 구조 포함):\\n\\n[펜션 한 줄 요약]\\n[위치 & 교통]\\n[객실 & 인원 안내]\\n[편의시설 상세]\\n[인근 관광지 추천 코스]\\n[이용 팁 & 주의사항]\\n[예약/문의 안내 + 예약링크]\\n\\n자연스러운 여행 블로그 글처럼 작성",
"description_${additionalLang?.code || 'en'}": "${additionalLang?.name || 'English'} 설명 (300~800자, 핵심 정보 위주, 한국 지명은 영어 표기 + 한글 병기)",
"tags_ko": ["한국어 태그 15~25개, 지역/펜션타입/타깃고객/계절/관광지 키워드 포함, 롱테일 키워드도 포함"],
"tags_${additionalLang?.code || 'en'}": ["${additionalLang?.name || 'English'} 태그 15~25개"],
"hashtags_ko": ["#형식의 해시태그 10~15개"],
"categoryId": "19"
},
"chapters": [
{"time": "00:00", "title_ko": "인트로 & 뷰 소개", "title_${additionalLang?.code || 'en'}": "Intro & View"},
{"time": "00:30", "title_ko": "외관 & 공용시설", "title_${additionalLang?.code || 'en'}": "Exterior & Facilities"},
{"time": "01:30", "title_ko": "객실 내부 투어", "title_${additionalLang?.code || 'en'}": "Room Tour"},
{"time": "03:00", "title_ko": "바비큐/수영장/스파", "title_${additionalLang?.code || 'en'}": "BBQ / Pool / Spa"},
{"time": "04:30", "title_ko": "주변 관광지 & 추천 코스", "title_${additionalLang?.code || 'en'}": "Nearby Attractions"},
{"time": "05:30", "title_ko": "예약 안내 & 팁", "title_${additionalLang?.code || 'en'}": "Booking Tips"}
],
"thumbnail_text": {
"short_ko": "${regionKo} ${pensionType}",
"short_${additionalLang?.code || 'en'}": "${regionEn} ${pensionTypeEn}",
"sub_ko": "핵심 강점 2개 요약",
"sub_${additionalLang?.code || 'en'}": "Key features summary"
},
"pinned_comment_ko": "고정 댓글용 한국어 멘트 (예약 링크, 문의 안내, 타임스탬프 요약 포함)",
"pinned_comment_${additionalLang?.code || 'en'}": "${additionalLang?.name || 'English'} pinned comment for foreign viewers"
}
[작성 규칙]
1) title_ko: 지역 + 펜션타입 + 강점1~2개 포함
2) description_ko: 첫 2~3줄에 핵심 키워드와 강력한 후킹 문장, 구조화된 섹션
3) description_${additionalLang?.code || 'en'}: 외국인 기준으로 위치/교통/시설 명확히
4) tags: 지역, 펜션타입, 타깃고객, 계절/테마, 주변관광지 키워드 모두 커버
5) chapters: 영상 길이(${videoDurationMin}분)에 맞는 타임스탬프
6) 톤: 과한 광고 피하고, 실제 방문 후기처럼 신뢰감 있게
예약 링크가 있으면 description에 반드시 포함: ${params.bookingUrl || '없음'}
`;
try {
const response = await ai.models.generateContent({
model: 'gemini-2.0-flash',
contents: { parts: [{ text: prompt }] }
});
let result = response.text || '';
result = result.replace(/```json/g, '').replace(/```/g, '').trim();
const seoData = JSON.parse(result);
// 태그 검증 (YouTube 제한: 500자)
['tags_ko', 'tags_en', 'tags_ja', 'tags_zh', 'tags_th', 'tags_vi'].forEach(tagKey => {
if (seoData.snippet?.[tagKey]) {
let totalTagLength = seoData.snippet[tagKey].join(',').length;
while (totalTagLength > 500 && seoData.snippet[tagKey].length > 5) {
seoData.snippet[tagKey].pop();
totalTagLength = seoData.snippet[tagKey].join(',').length;
}
}
});
// 제목 길이 검증 (100자)
Object.keys(seoData.snippet || {}).forEach(key => {
if (key.startsWith('title_') && seoData.snippet[key]?.length > 100) {
seoData.snippet[key] = seoData.snippet[key].substring(0, 97) + '...';
}
});
// 설명 길이 검증 (5000자)
Object.keys(seoData.snippet || {}).forEach(key => {
if (key.startsWith('description_') && seoData.snippet[key]?.length > 5000) {
seoData.snippet[key] = seoData.snippet[key].substring(0, 4997) + '...';
}
});
// 메타 정보 추가
seoData.meta = {
businessName: params.businessName,
businessNameEn: params.businessNameEn || params.businessName,
region: regionKo,
regionEn: regionEn,
pensionType: pensionType,
pensionTypeEn: pensionTypeEn,
bookingUrl: params.bookingUrl || '',
videoDuration: params.videoDuration,
language: params.language || 'KO',
additionalLanguage: additionalLang?.code || 'en'
};
console.log(`[YouTube SEO] ${params.businessName}: 다국어 메타데이터 생성 완료`);
return seoData;
} catch (error) {
console.error('[YouTube SEO] 생성 오류:', error.message);
// 폴백: 기본 메타데이터 반환
return {
snippet: {
title_ko: `${regionKo} ${pensionType} | ${params.businessName}`,
title_en: `${regionEn} ${pensionTypeEn} | ${params.businessNameEn || params.businessName}`,
description_ko: `${params.businessName}\n\n${params.description}\n\n📍 위치: ${params.address || regionKo}\n🔗 예약: ${params.bookingUrl || '문의'}\n\n#펜션 #숙소 #여행 #휴가 #${regionKo}`,
description_en: `${params.businessNameEn || params.businessName}\n\n📍 Location: ${params.address || regionEn}\n🔗 Booking: ${params.bookingUrl || 'Contact us'}\n\n#pension #accommodation #travel #korea`,
tags_ko: [params.businessName, pensionType, regionKo, '펜션', '숙소', '여행', '휴가', ...categoryTagsKo],
tags_en: [params.businessNameEn || params.businessName, pensionTypeEn, regionEn, 'pension', 'korea travel', ...categoryTagsEn],
hashtags_ko: [`#${params.businessName}`, `#${regionKo}펜션`, `#${pensionType}`, '#펜션추천', '#국내여행'],
categoryId: '19'
},
chapters: [
{ time: '0:00', title_ko: '인트로', title_en: 'Intro' },
{ time: '0:15', title_ko: '시설 소개', title_en: 'Facilities' },
{ time: '0:45', title_ko: '마무리', title_en: 'Outro' }
],
thumbnail_text: {
short_ko: `${regionKo} ${pensionType}`,
short_en: `${regionEn} ${pensionTypeEn}`,
sub_ko: params.mainStrengths?.[0] || '프라이빗 힐링',
sub_en: 'Private Retreat'
},
pinned_comment_ko: `📍 ${params.businessName} 예약 안내\n🔗 ${params.bookingUrl || '문의하기'}\n\n영상이 도움이 되셨다면 구독과 좋아요 부탁드려요! 🙏`,
pinned_comment_en: `📍 ${params.businessNameEn || params.businessName} Booking\n🔗 ${params.bookingUrl || 'Contact us'}\n\nIf you found this helpful, please subscribe! 🙏`,
meta: {
businessName: params.businessName,
businessNameEn: params.businessNameEn || params.businessName,
region: regionKo,
regionEn: regionEn,
bookingUrl: params.bookingUrl || '',
language: params.language || 'KO'
}
};
}
};
/**
* 비즈니스 DNA 분석 (Gemini 2.5 Flash + Google Search Grounding)
* 펜션/숙소의 브랜드 DNA를 분석하여 톤앤매너, 타겟 고객, 컬러, 키워드, 시각적 스타일을 추출
*
* @param {string} nameOrUrl - 펜션 이름 또는 URL
* @param {Array<{base64: string, mimeType: string}>} images - 펜션 이미지들 (선택)
* @param {number} userId - 사용자 ID (로깅용)
* @returns {Promise<object>} - BusinessDNA 객체
*/
const analyzeBusinessDNA = async (nameOrUrl, images = [], userId = null) => {
if (!GEMINI_API_KEY) {
throw new Error("Gemini API Key가 서버에 설정되지 않았습니다.");
}
const startTime = Date.now();
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
// 이미지 파트 준비
const imageParts = images.map((imageData) => ({
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 }
}));
// Google Search Grounding을 사용하는 프롬프트
const prompt = `
당신은 숙박업 브랜딩 전문가입니다.
**분석 대상**: "${nameOrUrl}"
**과제**: 이 펜션/숙소에 대해 Google 검색을 수행하여 다음 정보를 수집하고 분석하세요:
1. 공식 웹사이트, 예약 사이트(네이버, 야놀자, 여기어때 등)의 정보
2. 고객 리뷰 및 평점
3. 블로그 포스팅 및 SNS 언급
4. 사진에서 보이는 인테리어/익스테리어 스타일
${images.length > 0 ? '첨부된 이미지도 분석하여 시각적 스타일을 파악하세요.' : ''}
**출력 형식 (JSON)**:
반드시 아래 구조를 따르세요:
{
"name": "펜션 정식 명칭",
"tagline": "한 줄 슬로건 (10자 이내)",
"toneAndManner": {
"primary": "메인 톤앤매너 (예: Warm & Cozy, Luxurious & Elegant, Modern & Minimal)",
"secondary": "보조 톤앤매너 (선택)",
"description": "톤앤매너에 대한 상세 설명 (50자 내외)"
},
"targetCustomers": {
"primary": "주요 타겟 (예: Young Couples, Families with Kids, Solo Travelers)",
"secondary": ["보조 타겟1", "보조 타겟2"],
"ageRange": "예상 연령대 (예: 25-35)",
"characteristics": ["타겟 특성1", "타겟 특성2", "타겟 특성3"]
},
"brandColors": {
"primary": "#HEX코드 (브랜드 메인 컬러)",
"secondary": "#HEX코드 (보조 컬러)",
"accent": "#HEX코드 (악센트 컬러)",
"palette": ["#컬러1", "#컬러2", "#컬러3", "#컬러4", "#컬러5"],
"mood": "컬러가 주는 분위기 (예: Calm & Serene, Vibrant & Energetic)"
},
"keywords": {
"primary": ["핵심 키워드1", "핵심 키워드2", "핵심 키워드3", "핵심 키워드4", "핵심 키워드5"],
"secondary": ["부가 키워드1", "부가 키워드2", "부가 키워드3"],
"hashtags": ["#해시태그1", "#해시태그2", "#해시태그3", "#해시태그4", "#해시태그5"]
},
"visualStyle": {
"interior": "인테리어 스타일 (예: Scandinavian Minimalist, Korean Modern Hanok, Industrial Loft)",
"exterior": "외관 스타일 (예: Mountain Lodge, Seaside Villa, Forest Cabin)",
"atmosphere": "전반적인 분위기 (예: Serene & Peaceful, Romantic & Intimate, Vibrant & Social)",
"photoStyle": "추천 사진 스타일 (예: Warm Natural Light, Moody & Dramatic, Bright & Airy)",
"suggestedFilters": ["추천 필터1", "추천 필터2", "추천 필터3"]
},
"uniqueSellingPoints": [
"차별화 포인트1 (20자 이내)",
"차별화 포인트2",
"차별화 포인트3"
],
"mood": {
"primary": "메인 무드 (예: Relaxation, Adventure, Romance)",
"emotions": ["고객이 느낄 감정1", "감정2", "감정3", "감정4"]
},
"confidence": 0.85
}
**중요**:
- 모든 필드를 채워야 합니다
- 컬러는 반드시 유효한 HEX 코드로 작성 (#으로 시작)
- 컬러는 펜션의 분위기와 어울리게 선정
- 키워드와 해시태그는 한국어로 작성
- confidence는 정보의 신뢰도 (0.0~1.0)
`;
try {
// Google Search Grounding이 포함된 Gemini 2.5 Flash 호출
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-preview-05-20',
contents: {
parts: [
{ text: prompt },
...imageParts
]
},
config: {
temperature: 0.7,
responseMimeType: 'application/json',
// Google Search Grounding 활성화
tools: [{ googleSearch: {} }]
}
});
const latency = Date.now() - startTime;
// 로깅
await logApiUsage({
service: 'gemini',
model: 'gemini-2.5-flash-preview-05-20',
endpoint: 'analyze-dna',
userId,
imageCount: images.length,
latencyMs: latency,
costEstimate: 0.01,
metadata: { nameOrUrl, hasImages: images.length > 0 }
});
if (response.text) {
try {
const dna = JSON.parse(response.text);
// 분석 시간 추가
dna.analyzedAt = new Date().toISOString();
// 검색에서 사용된 소스 추출 (grounding metadata)
if (response.candidates?.[0]?.groundingMetadata?.groundingChunks) {
dna.sources = response.candidates[0].groundingMetadata.groundingChunks
.filter(chunk => chunk.web?.uri)
.map(chunk => chunk.web.uri)
.slice(0, 5);
}
return dna;
} catch (e) {
console.error("DNA JSON 파싱 오류:", response.text);
throw new Error("DNA 분석 결과 파싱에 실패했습니다.");
}
}
throw new Error("DNA 분석 응답이 비어있습니다.");
} catch (error) {
const latency = Date.now() - startTime;
await logApiUsage({
service: 'gemini',
model: 'gemini-2.5-flash-preview-05-20',
endpoint: 'analyze-dna',
userId,
status: 'error',
errorMessage: error.message,
latencyMs: latency
});
console.error('[DNA Analysis] 오류:', error.message);
throw error;
}
};
module.exports = {
generateCreativeContent,
generateAdvancedSpeech,
generateAdPoster,
generateImageGallery,
filterBestImages,
enrichDescriptionWithReviews,
extractTextEffectFromImage,
generateVideoBackground,
generateYouTubeSEO,
analyzeBusinessDNA,
};