391 lines
15 KiB
TypeScript
Executable File
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;
|