431 lines
15 KiB
TypeScript
Executable File
431 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;
|
|
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
|
|
onVideoProgressChange?: (progress: number) => void;
|
|
}
|
|
|
|
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,
|
|
onVideoStatusChange,
|
|
onVideoProgressChange
|
|
}) => {
|
|
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 [renderProgress, setRenderProgress] = useState(0); // 영상 렌더링 진행률 (0-100)
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [progress, setProgress] = useState(0);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const hasStartedGeneration = useRef(false);
|
|
|
|
// Notify parent of video status changes
|
|
useEffect(() => {
|
|
if (onVideoStatusChange) {
|
|
const mappedStatus = videoStatus === 'polling' ? 'generating' : videoStatus;
|
|
onVideoStatusChange(mappedStatus);
|
|
}
|
|
}, [videoStatus, onVideoStatusChange]);
|
|
|
|
// Notify parent of progress changes
|
|
useEffect(() => {
|
|
if (onVideoProgressChange) {
|
|
onVideoProgressChange(renderProgress);
|
|
}
|
|
}, [renderProgress, onVideoProgressChange]);
|
|
|
|
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 {
|
|
// Get video ratio from localStorage (default to 'vertical' if not set)
|
|
const savedRatio = localStorage.getItem('castad_video_ratio');
|
|
const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical';
|
|
|
|
const videoResponse = await generateVideo(songTaskId, orientation);
|
|
|
|
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 getProgressForStatus = (status: string): number => {
|
|
switch (status) {
|
|
case 'planned':
|
|
return 20;
|
|
case 'waiting':
|
|
return 40;
|
|
case 'transcribing':
|
|
return 60;
|
|
case 'rendering':
|
|
return 80;
|
|
case 'succeeded':
|
|
return 100;
|
|
default:
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
const pollVideoStatus = async (videoTaskId: string, currentSongTaskId: string) => {
|
|
try {
|
|
// 영상 생성 상태 폴링 (3분 타임아웃, 3초 간격)
|
|
const statusResponse = await waitForVideoComplete(
|
|
videoTaskId,
|
|
(status: string) => {
|
|
setStatusMessage(getStatusMessage(status));
|
|
setRenderProgress(getProgressForStatus(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', logo: '/assets/images/social-youtube.png' },
|
|
{ id: 'Instagram', email: 'o2ocorp@o2o.kr', logo: '/assets/images/social-instagram.png' },
|
|
{ id: 'Facebook', email: 'o2ocorp@o2o.kr', logo: '/assets/images/social-facebook.png' },
|
|
];
|
|
|
|
const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
|
|
|
|
return (
|
|
<main className="page-container">
|
|
{/* Header with Back Button */}
|
|
<div className="asset-header">
|
|
<button onClick={onBack} className="btn-back-new">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
<span>뒤로가기</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<h1 className="completion-title">
|
|
{isLoading ? '영상 생성 중' : videoStatus === 'error' ? '영상 생성 실패' : '콘텐츠 제작 완료'}
|
|
</h1>
|
|
|
|
{/* Main Content Container */}
|
|
<div className="completion-container">
|
|
{/* Left: Video Preview */}
|
|
<div className="completion-column completion-column-left video-preview-card">
|
|
<h3 className="asset-section-title">이미지 및 영상</h3>
|
|
|
|
<div className="completion-video-wrapper">
|
|
<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>
|
|
</div>
|
|
|
|
{/* AI Optimization Tags - only show when complete */}
|
|
{videoStatus === 'complete' && (
|
|
<div className="ai-optimization-section">
|
|
<h3 className="ai-optimization-title">AI 최적화</h3>
|
|
<div className="ai-optimization-tags">
|
|
{['색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => (
|
|
<div key={tag} className="ai-tag">
|
|
<div className="ai-tag-dot"></div>
|
|
<span className="ai-tag-text">{tag}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Sharing */}
|
|
<div className="completion-column completion-column-right sharing-card">
|
|
<div className="sharing-content">
|
|
<h3 className="asset-section-title">공유</h3>
|
|
|
|
<div className="social-list-new">
|
|
{socials.map(social => {
|
|
const isSelected = selectedSocials.includes(social.id);
|
|
return (
|
|
<div
|
|
key={social.id}
|
|
onClick={() => videoStatus === 'complete' && toggleSocial(social.id)}
|
|
className={`completion-social-card ${videoStatus !== 'complete' ? 'disabled' : ''}`}
|
|
>
|
|
<div className="completion-social-info">
|
|
<img src={social.logo} alt={social.id} className="completion-social-logo" />
|
|
<span className="completion-social-name">{social.id}</span>
|
|
</div>
|
|
<span className="completion-social-email">{social.email}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sharing-actions">
|
|
<button
|
|
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'}
|
|
className="btn-completion-deploy"
|
|
>
|
|
소셜 채널에 배포
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleDownload}
|
|
disabled={videoStatus !== 'complete' || !videoUrl}
|
|
className="btn-completion-download"
|
|
>
|
|
MP4 파일 다운로드
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default CompletionContent;
|