1217 lines
48 KiB
JavaScript
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,
|
|
}; |