o2o-castad-frontend/src/pages/Dashboard/CompletionContent.tsx

391 lines
15 KiB
TypeScript
Executable File

import React, { useState, useEffect, useRef } from 'react';
import { generateVideo, waitForVideoComplete } from '../../utils/api';
interface CompletionContentProps {
onBack: () => void;
songTaskId: string | null;
}
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
const VIDEO_STORAGE_KEY = 'castad_video_generation';
const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000; // 30분
interface SavedVideoState {
videoTaskId: string;
songTaskId: string;
status: VideoStatus;
videoUrl: string | null;
timestamp: number;
}
const CompletionContent: React.FC<CompletionContentProps> = ({ onBack, songTaskId }) => {
const [selectedSocials, setSelectedSocials] = useState<string[]>([]);
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const videoRef = useRef<HTMLVideoElement>(null);
const hasStartedGeneration = useRef(false);
const saveToStorage = (videoTaskId: string, currentSongTaskId: string, status: VideoStatus, url: string | null) => {
const data: SavedVideoState = {
videoTaskId,
songTaskId: currentSongTaskId,
status,
videoUrl: url,
timestamp: Date.now(),
};
localStorage.setItem(VIDEO_STORAGE_KEY, JSON.stringify(data));
};
const clearStorage = () => {
localStorage.removeItem(VIDEO_STORAGE_KEY);
};
const loadFromStorage = (): SavedVideoState | null => {
try {
const saved = localStorage.getItem(VIDEO_STORAGE_KEY);
if (!saved) return null;
const data: SavedVideoState = JSON.parse(saved);
if (Date.now() - data.timestamp > VIDEO_STORAGE_EXPIRY) {
clearStorage();
return null;
}
return data;
} catch {
clearStorage();
return null;
}
};
const startVideoGeneration = async () => {
if (!songTaskId || hasStartedGeneration.current) return;
hasStartedGeneration.current = true;
setVideoStatus('generating');
setStatusMessage('영상 생성을 요청하고 있습니다...');
setErrorMessage(null);
try {
const videoResponse = await generateVideo(songTaskId);
if (!videoResponse.success) {
throw new Error(videoResponse.error_message || '영상 생성 요청에 실패했습니다.');
}
setVideoStatus('polling');
setStatusMessage('영상을 생성하고 있습니다...');
// video/status API는 creatomate_render_id를 사용
saveToStorage(videoResponse.creatomate_render_id, songTaskId, 'polling', null);
await pollVideoStatus(videoResponse.creatomate_render_id, songTaskId);
} catch (error) {
console.error('Video generation failed:', error);
setVideoStatus('error');
setErrorMessage(error instanceof Error ? error.message : '영상 생성 중 오류가 발생했습니다.');
hasStartedGeneration.current = false;
clearStorage();
}
};
// 상태별 한글 메시지 매핑
const getStatusMessage = (status: string): string => {
switch (status) {
case 'planned':
return '예약됨';
case 'waiting':
return '대기 중';
case 'transcribing':
return '트랜스크립션 중';
case 'rendering':
return '렌더링 중';
case 'succeeded':
return '완료';
default:
return '처리 중...';
}
};
const pollVideoStatus = async (videoTaskId: string, currentSongTaskId: string) => {
try {
// 영상 생성 상태 폴링 (3분 타임아웃, 3초 간격)
const statusResponse = await waitForVideoComplete(
videoTaskId,
(status: string) => {
setStatusMessage(getStatusMessage(status));
}
);
// render_data.url에서 영상 URL 가져오기
const videoUrlFromResponse = statusResponse.render_data?.url;
if (videoUrlFromResponse) {
setVideoUrl(videoUrlFromResponse);
setVideoStatus('complete');
setStatusMessage('');
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse);
} else {
throw new Error('영상 URL을 받지 못했습니다.');
}
} catch (error) {
console.error('Video polling failed:', error);
if (error instanceof Error && error.message === 'TIMEOUT') {
setVideoStatus('error');
setErrorMessage('영상 생성 시간이 초과되었습니다. 다시 시도해주세요.');
} else {
setVideoStatus('error');
setErrorMessage(error instanceof Error ? error.message : '영상 생성 중 오류가 발생했습니다.');
}
hasStartedGeneration.current = false;
clearStorage();
}
};
// 컴포넌트 마운트 시 저장된 상태 확인 또는 영상 생성 시작
useEffect(() => {
const savedState = loadFromStorage();
// 저장된 상태가 있고, 같은 songTaskId인 경우
if (savedState && savedState.songTaskId === songTaskId) {
if (savedState.status === 'complete' && savedState.videoUrl) {
// 이미 완료된 경우
setVideoUrl(savedState.videoUrl);
setVideoStatus('complete');
hasStartedGeneration.current = true;
} else if (savedState.status === 'polling') {
// 폴링 중이었던 경우 다시 폴링
setVideoStatus('polling');
setStatusMessage('영상을 처리하고 있습니다... (새로고침 후 복구됨)');
hasStartedGeneration.current = true;
pollVideoStatus(savedState.videoTaskId, savedState.songTaskId);
}
} else if (songTaskId && !hasStartedGeneration.current) {
// 새로운 영상 생성 시작
startVideoGeneration();
}
}, [songTaskId]);
const toggleSocial = (id: string) => {
setSelectedSocials(prev =>
prev.includes(id)
? prev.filter(s => s !== id)
: [...prev, id]
);
};
const togglePlayPause = () => {
if (!videoRef.current || !videoUrl) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
};
const handleTimeUpdate = () => {
if (videoRef.current && videoRef.current.duration > 0) {
setProgress((videoRef.current.currentTime / videoRef.current.duration) * 100);
}
};
const handleVideoEnded = () => {
setIsPlaying(false);
setProgress(0);
};
const handleDownload = () => {
if (videoUrl) {
const link = document.createElement('a');
link.href = videoUrl;
link.download = 'castad_video.mp4';
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
const handleRetry = () => {
hasStartedGeneration.current = false;
setVideoStatus('idle');
setVideoUrl(null);
setErrorMessage(null);
clearStorage();
startVideoGeneration();
};
const socials = [
{ id: 'Youtube', email: 'o2ocorp@o2o.kr', color: '#ff0000', icon: <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 4-8 4z"/></svg> },
{ id: 'Instagram', email: 'o2ocorp@o2o.kr', color: '#e4405f', icon: <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259 0 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.791-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.209-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg> },
];
const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
return (
<main className="completion-container">
{/* Back Button */}
<div className="back-button-container">
<button onClick={onBack} className="btn-back">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
</div>
{/* Header */}
<div className="page-header">
<h1 className="page-title">
{isLoading ? '영상 생성 중' : videoStatus === 'error' ? '영상 생성 실패' : '콘텐츠 제작 완료'}
</h1>
<p className="page-subtitle">
{isLoading
? statusMessage || '잠시만 기다려주세요...'
: videoStatus === 'error'
? errorMessage || '오류가 발생했습니다.'
: '인스타그램 릴스 및 틱톡에 최적화된 고성능 영상을 제작했습니다.'}
</p>
</div>
{/* Main Content */}
<div className="completion-content">
{/* Left: Video Preview */}
<div className="video-preview-card">
<h3 className="section-title mb-4 shrink-0"> </h3>
<div className="video-container">
{isLoading ? (
/* Loading State */
<div className="video-loading">
<div className="loading-spinner">
<div className="loading-ring"></div>
<div className="loading-dot">
<div className="loading-dot-inner"></div>
</div>
</div>
<p className="text-gray-400 mt-4">{statusMessage}</p>
</div>
) : videoStatus === 'error' ? (
/* Error State */
<div className="video-error">
<svg className="w-16 h-16 text-red-500 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<p className="text-gray-400 mb-4">{errorMessage}</p>
<button onClick={handleRetry} className="btn-secondary">
</button>
</div>
) : videoUrl ? (
/* Video Player */
<video
ref={videoRef}
src={videoUrl}
className="video-player"
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
onClick={togglePlayPause}
/>
) : (
<div className="video-pattern"></div>
)}
{/* Video Player Controls - only show when video is ready */}
{videoStatus === 'complete' && videoUrl && (
<div className="video-controls">
<div className="video-controls-inner">
<button className="video-play-btn" onClick={togglePlayPause}>
{isPlaying ? (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
) : (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
)}
</button>
<div className="video-progress">
<div className="video-progress-fill" style={{ width: `${progress}%` }}></div>
</div>
</div>
</div>
)}
</div>
{/* Tags - only show when complete */}
{videoStatus === 'complete' && (
<div className="tags-container">
{['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => (
<span key={tag} className="tag-dot">
{tag}
</span>
))}
</div>
)}
</div>
{/* Right: Sharing */}
<div className="sharing-card">
<h3 className="section-title mb-6 shrink-0"></h3>
<div className="social-list custom-scrollbar">
{socials.map(social => {
const isSelected = selectedSocials.includes(social.id);
return (
<div
key={social.id}
onClick={() => videoStatus === 'complete' && toggleSocial(social.id)}
className={`social-card ${isSelected ? 'selected' : ''} ${videoStatus !== 'complete' ? 'disabled' : ''}`}
>
<div
className="social-icon"
style={{ backgroundColor: social.color }}
>
<div className="social-icon-inner">{social.icon}</div>
{isSelected && (
<div className="social-check">
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="4"><polyline points="20 6 9 17 4 12"/></svg>
</div>
)}
</div>
<span className="social-name">
{social.id}
</span>
<span className="social-email">{social.email}</span>
</div>
);
})}
</div>
<button
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'}
className="btn-deploy"
>
</button>
<button
onClick={handleDownload}
disabled={videoStatus !== 'complete' || !videoUrl}
className="btn-download"
>
MP4
</button>
</div>
</div>
</main>
);
};
export default CompletionContent;