브라우저가 키를 들고 외부 API 를 직접 부르던 구조를 api/gemini-image.js, api/creatomate-render.js 로 옮긴다. 브라우저는 채널·포맷·주제 선택값만 보내고, 서버가 허용 목록과 대조한 뒤 프롬프트·영상 구성을 만든다. 키는 서버 환경변수 GEMINI_API_KEY·CREATOMATE_API_KEY 에만 둔다. 출처는 infinith-demo·localhost 만 받는다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
83 lines
4.0 KiB
JavaScript
83 lines
4.0 KiB
JavaScript
/**
|
|
* 콘텐츠 스튜디오 이미지 생성 (Vercel 서버 함수). POST { channel, format, pillarId } → { ok, imageDataUrl, prompt }
|
|
*
|
|
* Gemini 키는 서버 환경변수 GEMINI_API_KEY 에만 둔다 (VITE_ 접두사·vite.config define 금지).
|
|
* 프롬프트는 브라우저가 아니라 여기서 선택값으로 만든다. 배경은 api/_studio.js 주석.
|
|
*/
|
|
import { guard, readSelection } from './_studio.js';
|
|
|
|
// 이미지·렌더 요청은 외부 API 응답을 기다리므로 실행 시간 한도를 명시한다.
|
|
export const config = { maxDuration: 60 };
|
|
|
|
const MODEL = 'gemini-2.5-flash-image';
|
|
|
|
const PILLAR_DESCRIPTIONS = {
|
|
safety: 'hospital safety systems, clean surgical rooms, CCTV monitoring, professional medical team, trust and reliability',
|
|
expertise: 'medical expertise, advanced surgical equipment, professional doctors, medical certifications, precision and skill',
|
|
results: 'natural beautiful results, before and after transformation, patient satisfaction, subtle elegant outcomes',
|
|
care: 'patient-centered care, warm consultation, personalized treatment plan, caring medical staff, comfortable clinic interior',
|
|
};
|
|
|
|
const CHANNEL_HINTS = {
|
|
youtube: 'YouTube thumbnail style, bold text overlay area, high contrast, eye-catching',
|
|
instagram: 'Instagram aesthetic, clean minimalist, lifestyle photography style, warm tones',
|
|
naver_blog: 'Korean blog header image, informative, medical illustration, clean layout',
|
|
tiktok: 'TikTok cover image, vertical format, trendy, dynamic, youthful',
|
|
facebook: 'Facebook ad creative, professional, compelling, clear messaging space',
|
|
};
|
|
|
|
function buildPrompt(sel) {
|
|
return [
|
|
'Generate a premium medical marketing image for a plastic surgery clinic.',
|
|
`Theme: ${sel.pillarId ? PILLAR_DESCRIPTIONS[sel.pillarId] : ''}`,
|
|
`Style: ${CHANNEL_HINTS[sel.channel] ?? ''}`,
|
|
`Aspect ratio: ${sel.aspectRatio}`,
|
|
'Color palette: soft purple (#7B2D8E), gold (#E8B931), warm white (#FAF8F5).',
|
|
'Premium, luxurious, trustworthy aesthetic.',
|
|
'No text or logos in the image.',
|
|
'Photorealistic, high quality, professional medical marketing.',
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
export default async function handler(req, res) {
|
|
if (guard(req, res, ['POST'])) return;
|
|
|
|
const key = process.env.GEMINI_API_KEY;
|
|
if (!key) {
|
|
console.error('[api/gemini-image] GEMINI_API_KEY 가 설정되지 않았습니다.');
|
|
return res.status(500).json({ ok: false, error: 'unconfigured' });
|
|
}
|
|
const sel = readSelection(req.body);
|
|
if (!sel) return res.status(400).json({ ok: false, error: 'bad selection' });
|
|
|
|
const prompt = buildPrompt(sel);
|
|
let upstream;
|
|
try {
|
|
upstream = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
|
|
body: JSON.stringify({
|
|
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
generationConfig: { responseModalities: ['TEXT', 'IMAGE'] },
|
|
}),
|
|
});
|
|
} catch (e) {
|
|
console.error('[api/gemini-image] 연결 실패', e);
|
|
return res.status(502).json({ ok: false, error: 'upstream unreachable' });
|
|
}
|
|
|
|
const data = await upstream.json().catch(() => null);
|
|
if (!upstream.ok) {
|
|
// 브라우저에는 상태만 넘기고, Google 의 상세 메시지는 서버 로그에만 남긴다.
|
|
console.error('[api/gemini-image] 업스트림 오류', upstream.status, JSON.stringify(data?.error ?? {}).slice(0, 300));
|
|
const status = upstream.status === 429 ? 429 : upstream.status === 400 ? 400 : 502;
|
|
return res.status(status).json({ ok: false, error: status === 429 ? 'rate limited' : 'upstream error' });
|
|
}
|
|
|
|
const parts = data?.candidates?.[0]?.content?.parts ?? [];
|
|
const image = parts.find((p) => p.inlineData?.mimeType?.startsWith('image/'))?.inlineData;
|
|
if (!image) return res.status(502).json({ ok: false, error: 'no image' });
|
|
|
|
return res.status(200).json({ ok: true, imageDataUrl: `data:${image.mimeType};base64,${image.data}`, prompt });
|
|
}
|