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
parent
200497fa1e
commit
9bf47f7d93
|
|
@ -3,6 +3,7 @@ import { motion } from 'motion/react';
|
|||
import { VideoFilled, FileTextFilled } from '../icons/FilledIcons';
|
||||
import { CHANNEL_OPTIONS, MUSIC_TRACKS, type StudioState, type GenerateOutputType } from '../../types/studio';
|
||||
import { generateImage, type GenerateResult } from '../../services/geminiImageGen';
|
||||
import { generateVideo, type VideoResult } from '../../services/creatomateVideoGen';
|
||||
|
||||
interface Props {
|
||||
studioState: StudioState;
|
||||
|
|
@ -15,6 +16,7 @@ type GenerateStatus = 'idle' | 'generating' | 'done' | 'error';
|
|||
export default function GeneratePreviewStep({ studioState, outputType, onOutputTypeChange }: Props) {
|
||||
const [status, setStatus] = useState<GenerateStatus>('idle');
|
||||
const [result, setResult] = useState<GenerateResult | null>(null);
|
||||
const [videoResult, setVideoResult] = useState<VideoResult | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const downloadRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
|
|
@ -37,24 +39,38 @@ export default function GeneratePreviewStep({ studioState, outputType, onOutputT
|
|||
setStatus('error');
|
||||
}
|
||||
} else {
|
||||
// Video: placeholder (Creatomate integration needed)
|
||||
setTimeout(() => setStatus('done'), 4000);
|
||||
try {
|
||||
const res = await generateVideo(studioState);
|
||||
setVideoResult(res);
|
||||
setStatus('done');
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : '영상 생성에 실패했습니다');
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
}, [studioState, outputType]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setStatus('idle');
|
||||
setResult(null);
|
||||
setVideoResult(null);
|
||||
setErrorMsg('');
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!result?.imageDataUrl || !downloadRef.current) return;
|
||||
if (!downloadRef.current) return;
|
||||
const link = downloadRef.current;
|
||||
|
||||
if (result?.imageDataUrl) {
|
||||
link.href = result.imageDataUrl;
|
||||
link.download = `INFINITH_${activeChannel?.label ?? 'content'}_${activeFormat?.key ?? 'image'}.png`;
|
||||
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 =
|
||||
activeFormat?.aspectRatio === '9:16' ? 'w-[240px] h-[426px]' :
|
||||
|
|
@ -126,7 +142,7 @@ export default function GeneratePreviewStep({ studioState, outputType, onOutputT
|
|||
|
||||
{status === 'done' && (
|
||||
<div className="mt-6 flex gap-3">
|
||||
{result?.imageDataUrl && (
|
||||
{(result?.imageDataUrl || videoResult?.videoUrl) && (
|
||||
<button
|
||||
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"
|
||||
|
|
@ -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
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue