feat: Gemini·Creatomate 호출을 서버 함수로 옮긴다

브라우저가 키를 들고 외부 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>
This commit is contained in:
Haewon Kam 2026-10-07 14:39:19 +09:00
parent 8667bf6e2e
commit 0b729759c3
5 changed files with 330 additions and 239 deletions

84
api/_studio.js Normal file
View File

@ -0,0 +1,84 @@
/**
* 콘텐츠 스튜디오 서버 공용 모듈 (Vercel 서버 함수 전용, '_' 로 시작해 주소로 열리지 않는다).
*
* 왜 서버에 두는가
* Gemini·Creatomate 키는 브라우저로 내려가면 공개다. 2026-10-07 공개 번들에서 Gemini 키가 발견됐고,
* 새 Gemini 키는 서비스 계정 바인딩 키라 웹사이트(리퍼러) 제한을 걸 수 없다. 그래서 호출을 서버로 옮긴다.
* 브라우저는 채널·포맷·주제 선택값만 보내고, 프롬프트와 영상 구성은 여기서 만든다.
* 임의의 프롬프트·렌더를 대신 실행해 주는 통로가 되지 않도록 선택값은 아래 목록에 있는 것만 받는다.
*
* 채널·포맷 목록은 src/types/studio.ts 의 CHANNEL_OPTIONS 와 같아야 한다 (서버 함수는 src 의 TS 를 불러올 수 없다).
*/
export const CHANNELS = {
youtube: { label: 'YouTube', formats: { shorts: ['Shorts', '9:16'], long_form: ['Long-form', '16:9'] } },
instagram: {
label: 'Instagram',
formats: { reels: ['Reels', '9:16'], carousel: ['Carousel', '1:1'], feed_image: ['Feed Image', '1:1'], stories: ['Stories', '9:16'] },
},
naver_blog: { label: 'Naver Blog', formats: { seo_post: ['SEO Post', '16:9'], faq_post: ['FAQ Post', '16:9'] } },
tiktok: { label: 'TikTok', formats: { short_video: ['Short Video', '9:16'] } },
facebook: { label: 'Facebook', formats: { ad_creative: ['Ad Creative', '1:1'], retarget_content: ['Retarget Content', '16:9'] } },
};
export const PILLARS = ['safety', 'expertise', 'results', 'care'];
/** 이 주소들에서 온 요청만 받는다. 키를 쓰는 유료 API 라 Origin 이 없는 요청(브라우저 밖 호출)도 받지 않는다. */
const ALLOWED_ORIGINS = [
'https://infinith-demo.vercel.app',
'http://localhost:3000',
'http://127.0.0.1:3000',
];
/**
* CORS·메서드·출처를 확인한다. 통과하면 null, 아니면 이미 응답을 보낸 뒤 true 를 돌려준다.
* @param {string[]} methods 허용 메서드
*/
export function guard(req, res, methods) {
const origin = req.headers.origin || '';
const allowed = ALLOWED_ORIGINS.includes(origin);
if (allowed) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Methods', [...methods, 'OPTIONS'].join(', '));
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
if (req.method === 'OPTIONS') {
res.status(allowed ? 204 : 403).end();
return true;
}
if (!methods.includes(req.method)) {
res.status(405).json({ ok: false, error: 'method not allowed' });
return true;
}
// 같은 출처 GET 은 브라우저가 Origin 을 붙이지 않을 수 있어 Referer 로도 확인한다.
const referer = req.headers.referer || '';
const sameSite = ALLOWED_ORIGINS.some((o) => referer.startsWith(o + '/'));
if (!allowed && !sameSite) {
res.status(403).json({ ok: false, error: 'forbidden origin' });
return true;
}
return null;
}
/**
* 브라우저가 보낸 선택값을 목록과 대조한다. 목록 밖이면 null.
* @returns {{ channel: string, channelLabel: string, format: string, formatLabel: string, aspectRatio: string, pillarId: string | null } | null}
*/
export function readSelection(body) {
const b = typeof body === 'string' ? safeParse(body) : body ?? {};
const ch = CHANNELS[b?.channel];
const fmt = ch?.formats[b?.format];
if (!ch || !fmt) return null;
const pillarId = b?.pillarId == null ? null : String(b.pillarId);
if (pillarId !== null && !PILLARS.includes(pillarId)) return null;
return { channel: b.channel, channelLabel: ch.label, format: b.format, formatLabel: fmt[0], aspectRatio: fmt[1], pillarId };
}
export function safeParse(s) {
try {
return JSON.parse(s);
} catch {
return null;
}
}

111
api/creatomate-render.js Normal file
View File

@ -0,0 +1,111 @@
/**
* 콘텐츠 스튜디오 영상 렌더 (Vercel 서버 함수).
* POST { channel, format, pillarId } → { ok, id, status, url? } 렌더 시작
* GET ?id=<renderId> → { ok, id, status, url?, error? } 렌더 상태
*
* Creatomate 키는 서버 환경변수 CREATOMATE_API_KEY 에만 둔다 (예전 VITE_CREATOMATE_API_KEY 는 번들에 들어가 공개됐다).
* 렌더는 수십 초 걸릴 수 있어 서버 함수가 기다리지 않고, 브라우저가 GET 으로 상태를 묻는다.
* 영상 구성은 브라우저가 아니라 여기서 선택값으로 만든다. 배경은 api/_studio.js 주석.
*/
import { guard, readSelection } from './_studio.js';
// 이미지·렌더 요청은 외부 API 응답을 기다리므로 실행 시간 한도를 명시한다.
export const config = { maxDuration: 30 };
const API_BASE = 'https://api.creatomate.com/v1';
const RENDER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ASPECT_DIMENSIONS = {
'9:16': { width: 1080, height: 1920 },
'16:9': { width: 1920, height: 1080 },
'1:1': { width: 1080, height: 1080 },
'4:5': { width: 1080, height: 1350 },
};
const PILLAR_TITLES = {
safety: { ko: '안전한 수술 시스템', en: 'Safe Surgery System' },
expertise: { ko: '전문 의료진', en: 'Expert Medical Team' },
results: { ko: '자연스러운 결과', en: 'Natural Results' },
care: { ko: '환자 중심 케어', en: 'Patient-Centered Care' },
};
/** 템플릿 없이 만드는 브랜드 슬라이드 영상 구성. 예전 src/services/creatomateVideoGen.ts 의 buildSource 와 같다. */
function buildSource(sel) {
const dims = ASPECT_DIMENSIONS[sel.aspectRatio] ?? ASPECT_DIMENSIONS['16:9'];
const pillar = sel.pillarId ? PILLAR_TITLES[sel.pillarId] : null;
const wide = dims.width > dims.height;
return {
output_format: 'mp4',
width: dims.width,
height: dims.height,
duration: 10,
elements: [
{ type: 'shape', shape: 'rectangle', width: '100%', height: '100%', fill_color: 'linear-gradient(180deg, #0A1128 0%, #1A2B5E 50%, #4F1DA1 100%)' },
{
type: 'text', text: pillar?.ko || 'INFINITH Marketing', font_family: 'Noto Sans KR', font_weight: '700',
font_size: wide ? '72px' : '56px', fill_color: '#FFFFFF', x: '50%', y: '40%', x_anchor: '50%', y_anchor: '50%',
width: '80%', text_align: 'center', animations: [{ type: 'text-appear', duration: 1, split: 'word' }],
},
{
type: 'text', text: pillar?.en || 'Premium Medical Marketing', font_family: 'Noto Sans KR', font_weight: '400',
font_size: wide ? '36px' : '28px', fill_color: 'rgba(255,255,255,0.7)', x: '50%', y: '55%', x_anchor: '50%', y_anchor: '50%',
width: '80%', text_align: 'center', animations: [{ type: 'fade', fade_in: true, duration: 0.8, start: 0.5 }],
},
{
type: 'shape', shape: 'rectangle', width: '120px', height: '4px', fill_color: '#6C5CE7', x: '50%', y: '48%',
x_anchor: '50%', y_anchor: '50%', animations: [{ type: 'wipe', duration: 0.6, start: 0.3 }],
},
{
type: 'text', text: `${sel.channelLabel} · ${sel.formatLabel}`, font_family: 'Noto Sans KR', font_weight: '500',
font_size: '20px', fill_color: 'rgba(255,255,255,0.5)', x: '50%', y: '90%', x_anchor: '50%', y_anchor: '50%', text_align: 'center',
},
],
};
}
/** Creatomate 응답에서 브라우저에 필요한 것만 넘긴다. */
const pick = (r) => ({ ok: true, id: r.id, status: r.status, url: r.url ?? null, error: r.error_message ?? null });
export default async function handler(req, res) {
if (guard(req, res, ['POST', 'GET'])) return;
const key = process.env.CREATOMATE_API_KEY;
if (!key) {
console.error('[api/creatomate-render] CREATOMATE_API_KEY 가 설정되지 않았습니다.');
return res.status(500).json({ ok: false, error: 'unconfigured' });
}
const auth = { Authorization: `Bearer ${key}` };
try {
if (req.method === 'GET') {
const id = String(req.query?.id ?? '');
if (!RENDER_ID.test(id)) return res.status(400).json({ ok: false, error: 'bad id' });
const r = await fetch(`${API_BASE}/renders/${id}`, { headers: auth });
if (!r.ok) {
console.error('[api/creatomate-render] 상태 조회 실패', r.status);
return res.status(502).json({ ok: false, error: 'status failed' });
}
return res.status(200).json(pick(await r.json()));
}
const sel = readSelection(req.body);
if (!sel) return res.status(400).json({ ok: false, error: 'bad selection' });
const r = await fetch(`${API_BASE}/renders`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ source: buildSource(sel) }),
});
if (!r.ok) {
const detail = await r.text().catch(() => '');
console.error('[api/creatomate-render] 렌더 요청 실패', r.status, detail.slice(0, 300));
const status = [401, 429, 400].includes(r.status) ? r.status : 502;
return res.status(status).json({ ok: false, error: `render ${status}` });
}
const renders = await r.json();
if (!renders?.[0]?.id) return res.status(502).json({ ok: false, error: 'no render id' });
return res.status(200).json(pick(renders[0]));
} catch (e) {
console.error('[api/creatomate-render] 연결 실패', e);
return res.status(502).json({ ok: false, error: 'upstream unreachable' });
}
}

82
api/gemini-image.js Normal file
View File

@ -0,0 +1,82 @@
/**
* 콘텐츠 스튜디오 이미지 생성 (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 });
}

View File

@ -1,202 +1,63 @@
import type { StudioState } from '../types/studio';
import { CHANNEL_OPTIONS } from '../types/studio';
const CREATOMATE_API_KEY = import.meta.env.VITE_CREATOMATE_API_KEY ?? '';
const API_BASE = 'https://api.creatomate.com/v1';
/**
* 콘텐츠 스튜디오 영상 생성. Creatomate 는 서버 함수(api/creatomate-render.js)에서만 호출한다.
* 브라우저는 키를 모르고 선택값만 보낸다. 렌더 시작 뒤 서버에 상태를 물어 완료를 기다린다 (2026-10-07).
*/
export interface VideoResult {
videoUrl: string;
prompt: string;
}
interface CreatomateRender {
id: string;
status: 'planned' | 'waiting' | 'transcribing' | 'rendering' | 'succeeded' | 'failed';
url?: string;
error_message?: string;
interface RenderState {
ok: boolean;
id?: string;
status?: 'planned' | 'waiting' | 'transcribing' | 'rendering' | 'succeeded' | 'failed';
url?: string | null;
error?: string | null;
}
const ASPECT_DIMENSIONS: Record<string, { width: number; height: number }> = {
'9:16': { width: 1080, height: 1920 },
'16:9': { width: 1920, height: 1080 },
'1:1': { width: 1080, height: 1080 },
'4:5': { width: 1080, height: 1350 },
};
const ENDPOINT = '/api/creatomate-render';
const PILLAR_TITLES: Record<string, { ko: string; en: string }> = {
safety: { ko: '안전한 수술 시스템', en: 'Safe Surgery System' },
expertise: { ko: '전문 의료진', en: 'Expert Medical Team' },
results: { ko: '자연스러운 결과', en: 'Natural Results' },
care: { ko: '환자 중심 케어', en: 'Patient-Centered Care' },
};
/**
* Build a Creatomate source object programmatically.
* This creates a simple branded slideshow video without needing
* a pre-built template — ideal for initial integration.
*/
function buildSource(state: StudioState) {
const channel = CHANNEL_OPTIONS.find(c => c.channel === state.channel);
const format = channel?.formats.find(f => f.key === state.format);
const dims = ASPECT_DIMENSIONS[format?.aspectRatio ?? '16:9'];
const pillar = state.pillarId ? PILLAR_TITLES[state.pillarId] : null;
return {
output_format: 'mp4',
width: dims.width,
height: dims.height,
duration: 10,
elements: [
// Background gradient
{
type: 'shape',
shape: 'rectangle',
width: '100%',
height: '100%',
fill_color: 'linear-gradient(180deg, #0A1128 0%, #1A2B5E 50%, #4F1DA1 100%)',
},
// Title text
{
type: 'text',
text: pillar?.ko || 'INFINITH Marketing',
font_family: 'Noto Sans KR',
font_weight: '700',
font_size: dims.width > dims.height ? '72px' : '56px',
fill_color: '#FFFFFF',
x: '50%',
y: '40%',
x_anchor: '50%',
y_anchor: '50%',
width: '80%',
text_align: 'center',
animations: [
{ type: 'text-appear', duration: 1, split: 'word' },
],
},
// Subtitle
{
type: 'text',
text: pillar?.en || 'Premium Medical Marketing',
font_family: 'Noto Sans KR',
font_weight: '400',
font_size: dims.width > dims.height ? '36px' : '28px',
fill_color: 'rgba(255,255,255,0.7)',
x: '50%',
y: '55%',
x_anchor: '50%',
y_anchor: '50%',
width: '80%',
text_align: 'center',
animations: [
{ type: 'fade', fade_in: true, duration: 0.8, start: 0.5 },
],
},
// Brand accent line
{
type: 'shape',
shape: 'rectangle',
width: '120px',
height: '4px',
fill_color: '#6C5CE7',
x: '50%',
y: '48%',
x_anchor: '50%',
y_anchor: '50%',
animations: [
{ type: 'wipe', duration: 0.6, start: 0.3 },
],
},
// Channel badge
{
type: 'text',
text: `${channel?.label || ''} · ${format?.label || ''}`,
font_family: 'Noto Sans KR',
font_weight: '500',
font_size: '20px',
fill_color: 'rgba(255,255,255,0.5)',
x: '50%',
y: '90%',
x_anchor: '50%',
y_anchor: '50%',
text_align: 'center',
},
],
};
}
async function pollRender(renderId: string, maxAttempts = 30): Promise<CreatomateRender> {
async function pollRender(renderId: string, maxAttempts = 30): Promise<RenderState> {
for (let i = 0; i < maxAttempts; i++) {
const res = await fetch(`${API_BASE}/renders/${renderId}`, {
headers: { Authorization: `Bearer ${CREATOMATE_API_KEY}` },
});
if (!res.ok) {
throw new Error(`렌더 상태 확인 실패 (${res.status})`);
}
const render: CreatomateRender = await res.json();
if (render.status === 'succeeded' && render.url) {
return render;
}
if (render.status === 'failed') {
throw new Error(render.error_message || '영상 렌더링에 실패했습니다');
}
// Wait 2 seconds before next poll
await new Promise(resolve => setTimeout(resolve, 2000));
const res = await fetch(`${ENDPOINT}?id=${encodeURIComponent(renderId)}`);
if (!res.ok) throw new Error(`렌더 상태 확인 실패 (${res.status})`);
const render: RenderState = await res.json();
if (render.status === 'succeeded' && render.url) return render;
if (render.status === 'failed') throw new Error(render.error || '영상 렌더링에 실패했습니다');
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error('영상 렌더링 시간이 초과되었습니다 — 다시 시도해주세요');
}
export async function generateVideo(state: StudioState): Promise<VideoResult> {
if (!CREATOMATE_API_KEY) {
throw new Error('Creatomate API 키가 설정되지 않았습니다');
}
const source = buildSource(state);
const prompt = `${state.channel}/${state.format} — ${state.pillarId || 'general'}`;
let renderResponse;
let res: Response;
try {
renderResponse = await fetch(`${API_BASE}/renders`, {
res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CREATOMATE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ source }),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel: state.channel, format: state.format, pillarId: state.pillarId }),
});
} catch (err) {
throw new Error('Creatomate 서버에 연결할 수 없습니다');
console.error('[generateVideo] 서버 연결 실패', err);
throw new Error('영상 생성 서버에 연결할 수 없습니다');
}
if (!renderResponse.ok) {
const status = renderResponse.status;
if (status === 401) throw new Error('Creatomate API 인증 실패 — 관리자에게 문의하세요');
if (status === 429) throw new Error('렌더 한도 초과 — 잠시 후 다시 시도해주세요');
if (status === 400) {
const body = await renderResponse.json().catch(() => ({}));
throw new Error(`영상 설정 오류: ${body.message || '잘못된 요청'}`);
}
throw new Error(`영상 생성 요청 실패 (${status})`);
const render: RenderState | null = await res.json().catch(() => null);
if (!res.ok || !render?.ok) {
if (res.status === 500) throw new Error('Creatomate API 키가 설정되지 않았습니다');
if (res.status === 401) throw new Error('Creatomate API 인증 실패 — 관리자에게 문의하세요');
if (res.status === 429) throw new Error('렌더 한도 초과 — 잠시 후 다시 시도해주세요');
if (res.status === 400) throw new Error('영상 설정 오류: 잘못된 요청');
throw new Error(`영상 생성 요청 실패 (${res.status})`);
}
if (!render.id) throw new Error('렌더 ID를 받지 못했습니다');
const renders: CreatomateRender[] = await renderResponse.json();
const render = renders[0];
if (!render?.id) {
throw new Error('렌더 ID를 받지 못했습니다');
}
// If already succeeded (fast render)
if (render.status === 'succeeded' && render.url) {
return { videoUrl: render.url, prompt };
}
// Poll for completion
if (render.status === 'succeeded' && render.url) return { videoUrl: render.url, prompt };
const completed = await pollRender(render.id);
return { videoUrl: completed.url!, prompt };
}

View File

@ -1,44 +1,9 @@
import { GoogleGenAI } from '@google/genai';
import type { StudioState } from '../types/studio';
import { CHANNEL_OPTIONS } from '../types/studio';
const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY ?? '' });
function buildPrompt(state: StudioState): string {
const channel = CHANNEL_OPTIONS.find(c => c.channel === state.channel);
const format = channel?.formats.find(f => f.key === state.format);
const pillarDescriptions: Record<string, string> = {
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 pillarContext = state.pillarId ? pillarDescriptions[state.pillarId] ?? '' : '';
const aspectRatio = format?.aspectRatio ?? '16:9';
const channelHints: Record<string, string> = {
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',
};
const channelHint = state.channel ? channelHints[state.channel] ?? '' : '';
return [
'Generate a premium medical marketing image for a plastic surgery clinic.',
`Theme: ${pillarContext}`,
`Style: ${channelHint}`,
`Aspect ratio: ${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(' ');
}
/**
* 콘텐츠 스튜디오 이미지 생성. Gemini 는 서버 함수(api/gemini-image.js)에서만 호출한다.
* 브라우저는 키를 모르고 선택값(채널·포맷·주제)만 보낸다. 프롬프트도 서버에서 만든다 (2026-10-07).
*/
export interface GenerateResult {
imageDataUrl: string;
@ -46,36 +11,24 @@ export interface GenerateResult {
}
export async function generateImage(state: StudioState): Promise<GenerateResult> {
const prompt = buildPrompt(state);
let response;
let res: Response;
try {
response = await client.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: prompt,
config: {
responseModalities: ['Text', 'Image'],
},
res = await fetch('/api/gemini-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel: state.channel, format: state.format, pillarId: state.pillarId }),
});
} catch (err: any) {
const code = err?.status ?? err?.code ?? err?.message?.match(/"code":(\d+)/)?.[1];
if (String(code) === '429' || err?.message?.includes('RESOURCE_EXHAUSTED')) {
throw new Error('API 요청 한도 초과 — 잠시 후 다시 시도해주세요');
}
if (String(code) === '400' || err?.message?.includes('INVALID_ARGUMENT')) {
throw new Error('API 설정 오류 — 관리자에게 문의하세요');
}
throw new Error('이미지 생성 중 오류가 발생했습니다');
} catch (err) {
console.error('[generateImage] 서버 연결 실패', err);
throw new Error('이미지 생성 서버에 연결할 수 없습니다');
}
// Extract image from response parts
const parts = response.candidates?.[0]?.content?.parts;
if (!parts) throw new Error('AI 응답을 받지 못했습니다');
const imagePart = parts.find((p: any) => p.inlineData?.mimeType?.startsWith('image/'));
if (!imagePart?.inlineData) throw new Error('이미지가 생성되지 않았습니다 — 다시 시도해주세요');
const { mimeType, data } = imagePart.inlineData;
const imageDataUrl = `data:${mimeType};base64,${data}`;
return { imageDataUrl, prompt };
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
if (res.status === 429) throw new Error('API 요청 한도 초과 — 잠시 후 다시 시도해주세요');
if (res.status === 400 || res.status === 500) throw new Error('API 설정 오류 — 관리자에게 문의하세요');
if (data?.error === 'no image') throw new Error('이미지가 생성되지 않았습니다 — 다시 시도해주세요');
throw new Error('이미지 생성 중 오류가 발생했습니다');
}
return { imageDataUrl: data.imageDataUrl, prompt: data.prompt };
}