/** * 콘텐츠 스튜디오 영상 렌더 (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' }); } }