브라우저가 키를 들고 외부 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>
112 lines
5.3 KiB
JavaScript
112 lines
5.3 KiB
JavaScript
/**
|
|
* 콘텐츠 스튜디오 영상 렌더 (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' });
|
|
}
|
|
}
|