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:
parent
8667bf6e2e
commit
0b729759c3
84
api/_studio.js
Normal file
84
api/_studio.js
Normal 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
111
api/creatomate-render.js
Normal 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
82
api/gemini-image.js
Normal 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 });
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user