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 { 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;
|
||||||
link.href = result.imageDataUrl;
|
|
||||||
link.download = `INFINITH_${activeChannel?.label ?? 'content'}_${activeFormat?.key ?? 'image'}.png`;
|
if (result?.imageDataUrl) {
|
||||||
link.click();
|
link.href = result.imageDataUrl;
|
||||||
}, [result, activeChannel, activeFormat]);
|
link.download = `INFINITH_${activeChannel?.label ?? 'content'}_${activeFormat?.key ?? 'image'}.png`;
|
||||||
|
link.click();
|
||||||
|
} 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 }}
|
||||||
|
|
|
||||||
|
|
@ -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