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

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;