237 lines
10 KiB
TypeScript
237 lines
10 KiB
TypeScript
import { useState, useCallback, useRef } from 'react';
|
|
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';
|
|
|
|
interface Props {
|
|
studioState: StudioState;
|
|
outputType: GenerateOutputType;
|
|
onOutputTypeChange: (type: GenerateOutputType) => void;
|
|
}
|
|
|
|
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 [errorMsg, setErrorMsg] = useState('');
|
|
const downloadRef = useRef<HTMLAnchorElement>(null);
|
|
|
|
const activeChannel = CHANNEL_OPTIONS.find(c => c.channel === studioState.channel);
|
|
const activeFormat = activeChannel?.formats.find(f => f.key === studioState.format);
|
|
const activeTrack = MUSIC_TRACKS.find(t => t.id === studioState.sound.trackId);
|
|
|
|
const handleGenerate = useCallback(async () => {
|
|
setStatus('generating');
|
|
setErrorMsg('');
|
|
setResult(null);
|
|
|
|
if (outputType === 'image') {
|
|
try {
|
|
const res = await generateImage(studioState);
|
|
setResult(res);
|
|
setStatus('done');
|
|
} catch (err) {
|
|
setErrorMsg(err instanceof Error ? err.message : 'Image generation failed');
|
|
setStatus('error');
|
|
}
|
|
} else {
|
|
// Video: placeholder (Creatomate integration needed)
|
|
setTimeout(() => setStatus('done'), 4000);
|
|
}
|
|
}, [studioState, outputType]);
|
|
|
|
const handleReset = useCallback(() => {
|
|
setStatus('idle');
|
|
setResult(null);
|
|
setErrorMsg('');
|
|
}, []);
|
|
|
|
const handleDownload = useCallback(() => {
|
|
if (!result?.imageDataUrl || !downloadRef.current) return;
|
|
const link = downloadRef.current;
|
|
link.href = result.imageDataUrl;
|
|
link.download = `INFINITH_${activeChannel?.label ?? 'content'}_${activeFormat?.key ?? 'image'}.png`;
|
|
link.click();
|
|
}, [result, activeChannel, activeFormat]);
|
|
|
|
const aspectClass =
|
|
activeFormat?.aspectRatio === '9:16' ? 'w-[240px] h-[426px]' :
|
|
activeFormat?.aspectRatio === '1:1' ? 'w-[340px] h-[340px]' :
|
|
activeFormat?.aspectRatio === '4:5' ? 'w-[300px] h-[375px]' :
|
|
'w-[426px] h-[240px]';
|
|
|
|
return (
|
|
<div>
|
|
{/* Hidden download anchor */}
|
|
<a ref={downloadRef} className="hidden" />
|
|
|
|
{/* Output Type Tabs */}
|
|
<div className="flex gap-2 mb-8">
|
|
{([
|
|
{ key: 'image' as const, label: '이미지 생성', Icon: FileTextFilled },
|
|
{ key: 'video' as const, label: '영상 생성', Icon: VideoFilled },
|
|
]).map(tab => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => { onOutputTypeChange(tab.key); handleReset(); }}
|
|
className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-medium transition-all ${
|
|
outputType === tab.key
|
|
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-md'
|
|
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<tab.Icon size={16} className={outputType === tab.key ? 'text-white' : 'text-[#9B8AD4]'} />
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
{/* Settings Summary */}
|
|
<div>
|
|
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">설정 요약</h3>
|
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 space-y-4">
|
|
<SummaryRow label="채널" value={activeChannel?.label ?? '-'} />
|
|
<SummaryRow label="포맷" value={activeFormat?.label ?? '-'} />
|
|
<SummaryRow label="비율" value={activeFormat?.aspectRatio ?? '-'} />
|
|
<SummaryRow label="음악" value={activeTrack?.name ?? (studioState.sound.genre === 'none' ? '없음' : '미선택')} />
|
|
<SummaryRow label="나레이션" value={studioState.sound.narrationEnabled
|
|
? `${studioState.sound.narrationLanguage.toUpperCase()} / ${studioState.sound.narrationVoice === 'female' ? 'Female' : 'Male'}`
|
|
: '없음'
|
|
} />
|
|
<SummaryRow label="자막" value={studioState.sound.subtitleEnabled ? 'ON' : 'OFF'} />
|
|
<SummaryRow label="출력" value={outputType === 'image' ? '이미지' : '영상'} />
|
|
</div>
|
|
|
|
{/* Generate Button */}
|
|
{(status === 'idle' || status === 'error') && (
|
|
<button
|
|
onClick={handleGenerate}
|
|
className="mt-6 w-full flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
|
|
>
|
|
{outputType === 'image' ? (
|
|
<FileTextFilled size={18} className="text-white" />
|
|
) : (
|
|
<VideoFilled size={18} className="text-white" />
|
|
)}
|
|
{status === 'error' ? '다시 시도' : outputType === 'image' ? '이미지 생성' : '영상 생성'}
|
|
</button>
|
|
)}
|
|
|
|
{status === 'error' && (
|
|
<p className="mt-3 text-sm text-[#7C3A4B] text-center">{errorMsg}</p>
|
|
)}
|
|
|
|
{status === 'done' && (
|
|
<div className="mt-6 flex gap-3">
|
|
{result?.imageDataUrl && (
|
|
<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"
|
|
>
|
|
다운로드
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleReset}
|
|
className="flex-1 py-3 rounded-full bg-white border border-slate-200 text-slate-600 text-sm font-medium shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:bg-slate-50 transition-all"
|
|
>
|
|
새로 생성
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Preview Area */}
|
|
<div className="flex flex-col items-center">
|
|
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4 self-start">프리뷰</h3>
|
|
|
|
<div className={`${aspectClass} rounded-2xl overflow-hidden border border-slate-200 bg-slate-50 flex items-center justify-center relative`}>
|
|
{status === 'idle' && (
|
|
<div className="text-center px-6">
|
|
<div className="w-16 h-16 rounded-2xl bg-[#F3F0FF] flex items-center justify-center mx-auto mb-4">
|
|
{outputType === 'image' ? (
|
|
<FileTextFilled size={28} className="text-[#9B8AD4]" />
|
|
) : (
|
|
<VideoFilled size={28} className="text-[#9B8AD4]" />
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-slate-400">
|
|
설정을 확인하고 생성 버튼을 눌러주세요
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'generating' && (
|
|
<div className="text-center">
|
|
<div className="w-12 h-12 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
|
<p className="text-sm text-slate-500 mb-1">
|
|
{outputType === 'image' ? '이미지' : '영상'} 생성 중...
|
|
</p>
|
|
<p className="text-xs text-slate-400">AI가 콘텐츠를 제작하고 있습니다</p>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'error' && (
|
|
<div className="text-center px-6">
|
|
<div className="w-14 h-14 rounded-full bg-[#FFF0F0] flex items-center justify-center mx-auto mb-4">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
<circle cx="12" cy="12" r="10" fill="#D4889A" opacity="0.3" />
|
|
<path d="M12 8v4M12 16h.01" stroke="#7C3A4B" strokeWidth="2" strokeLinecap="round" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-sm text-[#7C3A4B]">{errorMsg}</p>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'done' && result?.imageDataUrl && (
|
|
<motion.img
|
|
src={result.imageDataUrl}
|
|
alt="Generated content"
|
|
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 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.5 }}
|
|
className="absolute inset-0 bg-gradient-to-br from-[#F3F0FF] via-white to-[#FFF6ED] flex flex-col items-center justify-center p-6"
|
|
>
|
|
<div className="w-14 h-14 rounded-full bg-[#6C5CE7] flex items-center justify-center mb-4">
|
|
<svg width="24" height="24" viewBox="0 0 14 14" fill="none">
|
|
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-lg font-semibold text-[#0A1128] mb-1">생성 완료</p>
|
|
<p className="text-sm text-slate-500 text-center">
|
|
{activeChannel?.label} {activeFormat?.label}
|
|
{outputType === 'image' ? ' 이미지' : ' 영상'}이 준비되었습니다
|
|
</p>
|
|
<div className="mt-4 px-4 py-2 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]">
|
|
{activeFormat?.aspectRatio} | {outputType === 'image' ? 'PNG' : 'MP4'}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SummaryRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex items-center justify-between py-2 border-b border-slate-50 last:border-0">
|
|
<span className="text-sm text-slate-500">{label}</span>
|
|
<span className="text-sm font-medium text-[#0A1128]">{value}</span>
|
|
</div>
|
|
);
|
|
}
|