feat: Creatomate API integration — real video generation in Content Studio

- Add creatomateVideoGen.ts service with polling-based async rendering
- Replace video stub (setTimeout) with actual Creatomate API calls
- Add video preview (<video> tag) and MP4 download support
- Build programmatic source (branded slideshow) without pre-built templates
- Error handling: auth, rate limit, render failure → Korean messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-03 14:13:07 +09:00
parent 200497fa1e
commit 9bf47f7d93
2 changed files with 241 additions and 9 deletions

View File

@ -3,6 +3,7 @@ import { motion } from 'motion/react';
import { VideoFilled, FileTextFilled } from '../icons/FilledIcons'; import { VideoFilled, FileTextFilled } from '../icons/FilledIcons';
import { CHANNEL_OPTIONS, MUSIC_TRACKS, type StudioState, type GenerateOutputType } from '../../types/studio'; import { CHANNEL_OPTIONS, MUSIC_TRACKS, type StudioState, type GenerateOutputType } from '../../types/studio';
import { generateImage, type GenerateResult } from '../../services/geminiImageGen'; import { generateImage, type GenerateResult } from '../../services/geminiImageGen';
import { generateVideo, type VideoResult } from '../../services/creatomateVideoGen';
interface Props { interface Props {
studioState: StudioState; studioState: StudioState;
@ -15,6 +16,7 @@ type GenerateStatus = 'idle' | 'generating' | 'done' | 'error';
export default function GeneratePreviewStep({ studioState, outputType, onOutputTypeChange }: Props) { export default function GeneratePreviewStep({ studioState, outputType, onOutputTypeChange }: Props) {
const [status, setStatus] = useState<GenerateStatus>('idle'); const [status, setStatus] = useState<GenerateStatus>('idle');
const [result, setResult] = useState<GenerateResult | null>(null); const [result, setResult] = useState<GenerateResult | null>(null);
const [videoResult, setVideoResult] = useState<VideoResult | null>(null);
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
const downloadRef = useRef<HTMLAnchorElement>(null); const downloadRef = useRef<HTMLAnchorElement>(null);
@ -37,24 +39,38 @@ export default function GeneratePreviewStep({ studioState, outputType, onOutputT
setStatus('error'); setStatus('error');
} }
} else { } else {
// Video: placeholder (Creatomate integration needed) try {
setTimeout(() => setStatus('done'), 4000); const res = await generateVideo(studioState);
setVideoResult(res);
setStatus('done');
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : '영상 생성에 실패했습니다');
setStatus('error');
}
} }
}, [studioState, outputType]); }, [studioState, outputType]);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setStatus('idle'); setStatus('idle');
setResult(null); setResult(null);
setVideoResult(null);
setErrorMsg(''); setErrorMsg('');
}, []); }, []);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
if (!result?.imageDataUrl || !downloadRef.current) return; if (!downloadRef.current) return;
const link = downloadRef.current; const link = downloadRef.current;
if (result?.imageDataUrl) {
link.href = result.imageDataUrl; link.href = result.imageDataUrl;
link.download = `INFINITH_${activeChannel?.label ?? 'content'}_${activeFormat?.key ?? 'image'}.png`; link.download = `INFINITH_${activeChannel?.label ?? 'content'}_${activeFormat?.key ?? 'image'}.png`;
link.click(); link.click();
}, [result, activeChannel, activeFormat]); } else if (videoResult?.videoUrl) {
link.href = videoResult.videoUrl;
link.download = `INFINITH_${activeChannel?.label ?? 'content'}_${activeFormat?.key ?? 'video'}.mp4`;
link.click();
}
}, [result, videoResult, activeChannel, activeFormat]);
const aspectClass = const aspectClass =
activeFormat?.aspectRatio === '9:16' ? 'w-[240px] h-[426px]' : activeFormat?.aspectRatio === '9:16' ? 'w-[240px] h-[426px]' :
@ -126,7 +142,7 @@ export default function GeneratePreviewStep({ studioState, outputType, onOutputT
{status === 'done' && ( {status === 'done' && (
<div className="mt-6 flex gap-3"> <div className="mt-6 flex gap-3">
{result?.imageDataUrl && ( {(result?.imageDataUrl || videoResult?.videoUrl) && (
<button <button
onClick={handleDownload} onClick={handleDownload}
className="flex-1 py-3 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all" className="flex-1 py-3 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
@ -197,7 +213,21 @@ export default function GeneratePreviewStep({ studioState, outputType, onOutputT
/> />
)} )}
{status === 'done' && !result?.imageDataUrl && ( {status === 'done' && videoResult?.videoUrl && (
<motion.video
src={videoResult.videoUrl}
controls
autoPlay
loop
muted
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{status === 'done' && !result?.imageDataUrl && !videoResult?.videoUrl && (
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}

View File

@ -0,0 +1,202 @@
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';
export interface VideoResult {
videoUrl: string;
prompt: string;
}
interface CreatomateRender {
id: string;
status: 'planned' | 'waiting' | 'transcribing' | 'rendering' | 'succeeded' | 'failed';
url?: string;
error_message?: string;
}
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 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> {
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));
}
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;
try {
renderResponse = await fetch(`${API_BASE}/renders`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CREATOMATE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ source }),
});
} catch (err) {
throw new Error('Creatomate 서버에 연결할 수 없습니다');
}
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 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
const completed = await pollRender(render.id);
return { videoUrl: completed.url!, prompt };
}