From 0b729759c39e4493a76a897ed852a0d6c0b4fa00 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Wed, 7 Oct 2026 14:39:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Gemini=C2=B7Creatomate=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EC=9D=84=20=EC=84=9C=EB=B2=84=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저가 키를 들고 외부 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 --- api/_studio.js | 84 ++++++++++++ api/creatomate-render.js | 111 ++++++++++++++++ api/gemini-image.js | 82 ++++++++++++ src/services/creatomateVideoGen.ts | 205 +++++------------------------ src/services/geminiImageGen.ts | 87 +++--------- 5 files changed, 330 insertions(+), 239 deletions(-) create mode 100644 api/_studio.js create mode 100644 api/creatomate-render.js create mode 100644 api/gemini-image.js diff --git a/api/_studio.js b/api/_studio.js new file mode 100644 index 0000000..ac65548 --- /dev/null +++ b/api/_studio.js @@ -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; + } +} diff --git a/api/creatomate-render.js b/api/creatomate-render.js new file mode 100644 index 0000000..9327005 --- /dev/null +++ b/api/creatomate-render.js @@ -0,0 +1,111 @@ +/** + * 콘텐츠 스튜디오 영상 렌더 (Vercel 서버 함수). + * POST { channel, format, pillarId } → { ok, id, status, url? } 렌더 시작 + * GET ?id= → { 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' }); + } +} diff --git a/api/gemini-image.js b/api/gemini-image.js new file mode 100644 index 0000000..130f6b1 --- /dev/null +++ b/api/gemini-image.js @@ -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 }); +} diff --git a/src/services/creatomateVideoGen.ts b/src/services/creatomateVideoGen.ts index 14a537c..a31382b 100644 --- a/src/services/creatomateVideoGen.ts +++ b/src/services/creatomateVideoGen.ts @@ -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 = { - '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 = { - 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 { +async function pollRender(renderId: string, maxAttempts = 30): Promise { 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 { - 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 }; } diff --git a/src/services/geminiImageGen.ts b/src/services/geminiImageGen.ts index 747a2b9..f407c46 100644 --- a/src/services/geminiImageGen.ts +++ b/src/services/geminiImageGen.ts @@ -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 = { - 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 = { - 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 { - 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 }; }