o2o-infinith-demo/src/components/studio/GeneratePreviewStep.tsx

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>
);
}