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 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 {
|
export interface VideoResult {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreatomateRender {
|
interface RenderState {
|
||||||
id: string;
|
ok: boolean;
|
||||||
status: 'planned' | 'waiting' | 'transcribing' | 'rendering' | 'succeeded' | 'failed';
|
id?: string;
|
||||||
url?: string;
|
status?: 'planned' | 'waiting' | 'transcribing' | 'rendering' | 'succeeded' | 'failed';
|
||||||
error_message?: string;
|
url?: string | null;
|
||||||
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ASPECT_DIMENSIONS: Record<string, { width: number; height: number }> = {
|
const ENDPOINT = '/api/creatomate-render';
|
||||||
'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: Record<string, { ko: string; en: string }> = {
|
async function pollRender(renderId: string, maxAttempts = 30): Promise<RenderState> {
|
||||||
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> {
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
const res = await fetch(`${API_BASE}/renders/${renderId}`, {
|
const res = await fetch(`${ENDPOINT}?id=${encodeURIComponent(renderId)}`);
|
||||||
headers: { Authorization: `Bearer ${CREATOMATE_API_KEY}` },
|
if (!res.ok) throw new Error(`렌더 상태 확인 실패 (${res.status})`);
|
||||||
});
|
const render: RenderState = await res.json();
|
||||||
|
if (render.status === 'succeeded' && render.url) return render;
|
||||||
if (!res.ok) {
|
if (render.status === 'failed') throw new Error(render.error || '영상 렌더링에 실패했습니다');
|
||||||
throw new Error(`렌더 상태 확인 실패 (${res.status})`);
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('영상 렌더링 시간이 초과되었습니다 — 다시 시도해주세요');
|
throw new Error('영상 렌더링 시간이 초과되었습니다 — 다시 시도해주세요');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateVideo(state: StudioState): Promise<VideoResult> {
|
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'}`;
|
const prompt = `${state.channel}/${state.format} — ${state.pillarId || 'general'}`;
|
||||||
|
|
||||||
let renderResponse;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
renderResponse = await fetch(`${API_BASE}/renders`, {
|
res = await fetch(ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Authorization': `Bearer ${CREATOMATE_API_KEY}`,
|
body: JSON.stringify({ channel: state.channel, format: state.format, pillarId: state.pillarId }),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ source }),
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error('Creatomate 서버에 연결할 수 없습니다');
|
console.error('[generateVideo] 서버 연결 실패', err);
|
||||||
|
throw new Error('영상 생성 서버에 연결할 수 없습니다');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!renderResponse.ok) {
|
const render: RenderState | null = await res.json().catch(() => null);
|
||||||
const status = renderResponse.status;
|
if (!res.ok || !render?.ok) {
|
||||||
if (status === 401) throw new Error('Creatomate API 인증 실패 — 관리자에게 문의하세요');
|
if (res.status === 500) throw new Error('Creatomate API 키가 설정되지 않았습니다');
|
||||||
if (status === 429) throw new Error('렌더 한도 초과 — 잠시 후 다시 시도해주세요');
|
if (res.status === 401) throw new Error('Creatomate API 인증 실패 — 관리자에게 문의하세요');
|
||||||
if (status === 400) {
|
if (res.status === 429) throw new Error('렌더 한도 초과 — 잠시 후 다시 시도해주세요');
|
||||||
const body = await renderResponse.json().catch(() => ({}));
|
if (res.status === 400) throw new Error('영상 설정 오류: 잘못된 요청');
|
||||||
throw new Error(`영상 설정 오류: ${body.message || '잘못된 요청'}`);
|
throw new Error(`영상 생성 요청 실패 (${res.status})`);
|
||||||
}
|
|
||||||
throw new Error(`영상 생성 요청 실패 (${status})`);
|
|
||||||
}
|
}
|
||||||
|
if (!render.id) throw new Error('렌더 ID를 받지 못했습니다');
|
||||||
|
|
||||||
const renders: CreatomateRender[] = await renderResponse.json();
|
if (render.status === 'succeeded' && render.url) return { videoUrl: render.url, prompt };
|
||||||
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
|
|
||||||
const completed = await pollRender(render.id);
|
const completed = await pollRender(render.id);
|
||||||
return { videoUrl: completed.url!, prompt };
|
return { videoUrl: completed.url!, prompt };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +1,9 @@
|
|||||||
import { GoogleGenAI } from '@google/genai';
|
|
||||||
import type { StudioState } from '../types/studio';
|
import type { StudioState } from '../types/studio';
|
||||||
import { CHANNEL_OPTIONS } from '../types/studio';
|
|
||||||
|
|
||||||
const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY ?? '' });
|
/**
|
||||||
|
* 콘텐츠 스튜디오 이미지 생성. Gemini 는 서버 함수(api/gemini-image.js)에서만 호출한다.
|
||||||
function buildPrompt(state: StudioState): string {
|
* 브라우저는 키를 모르고 선택값(채널·포맷·주제)만 보낸다. 프롬프트도 서버에서 만든다 (2026-10-07).
|
||||||
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(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GenerateResult {
|
export interface GenerateResult {
|
||||||
imageDataUrl: string;
|
imageDataUrl: string;
|
||||||
@ -46,36 +11,24 @@ export interface GenerateResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateImage(state: StudioState): Promise<GenerateResult> {
|
export async function generateImage(state: StudioState): Promise<GenerateResult> {
|
||||||
const prompt = buildPrompt(state);
|
let res: Response;
|
||||||
|
|
||||||
let response;
|
|
||||||
try {
|
try {
|
||||||
response = await client.models.generateContent({
|
res = await fetch('/api/gemini-image', {
|
||||||
model: 'gemini-2.5-flash-image',
|
method: 'POST',
|
||||||
contents: prompt,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
config: {
|
body: JSON.stringify({ channel: state.channel, format: state.format, pillarId: state.pillarId }),
|
||||||
responseModalities: ['Text', 'Image'],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
const code = err?.status ?? err?.code ?? err?.message?.match(/"code":(\d+)/)?.[1];
|
console.error('[generateImage] 서버 연결 실패', err);
|
||||||
if (String(code) === '429' || err?.message?.includes('RESOURCE_EXHAUSTED')) {
|
throw new Error('이미지 생성 서버에 연결할 수 없습니다');
|
||||||
throw new Error('API 요청 한도 초과 — 잠시 후 다시 시도해주세요');
|
|
||||||
}
|
|
||||||
if (String(code) === '400' || err?.message?.includes('INVALID_ARGUMENT')) {
|
|
||||||
throw new Error('API 설정 오류 — 관리자에게 문의하세요');
|
|
||||||
}
|
|
||||||
throw new Error('이미지 생성 중 오류가 발생했습니다');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract image from response parts
|
const data = await res.json().catch(() => null);
|
||||||
const parts = response.candidates?.[0]?.content?.parts;
|
if (!res.ok || !data?.ok) {
|
||||||
if (!parts) throw new Error('AI 응답을 받지 못했습니다');
|
if (res.status === 429) throw new Error('API 요청 한도 초과 — 잠시 후 다시 시도해주세요');
|
||||||
|
if (res.status === 400 || res.status === 500) throw new Error('API 설정 오류 — 관리자에게 문의하세요');
|
||||||
const imagePart = parts.find((p: any) => p.inlineData?.mimeType?.startsWith('image/'));
|
if (data?.error === 'no image') throw new Error('이미지가 생성되지 않았습니다 — 다시 시도해주세요');
|
||||||
if (!imagePart?.inlineData) throw new Error('이미지가 생성되지 않았습니다 — 다시 시도해주세요');
|
throw new Error('이미지 생성 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
const { mimeType, data } = imagePart.inlineData;
|
return { imageDataUrl: data.imageDataUrl, prompt: data.prompt };
|
||||||
const imageDataUrl = `data:${mimeType};base64,${data}`;
|
|
||||||
return { imageDataUrl, prompt };
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user