474 lines
16 KiB
TypeScript
Executable File
474 lines
16 KiB
TypeScript
Executable File
|
||
import React, { useState, useEffect, useRef } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { generateVideo, waitForVideoComplete } from '../../utils/api';
|
||
import SocialPostingModal from '../../components/SocialPostingModal';
|
||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
||
|
||
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_COMPLETE_KEY = 'castad_video_complete';
|
||
const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000;
|
||
|
||
interface SavedVideoState {
|
||
videoTaskId: string;
|
||
songTaskId: string;
|
||
status: VideoStatus;
|
||
videoUrl: string | null;
|
||
videoDbId?: number;
|
||
timestamp: number;
|
||
}
|
||
|
||
const CompletionContent: React.FC<CompletionContentProps> = ({
|
||
onBack,
|
||
songTaskId,
|
||
onVideoStatusChange,
|
||
onVideoProgressChange
|
||
}) => {
|
||
const { t } = useTranslation();
|
||
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);
|
||
const hasStartedGeneration = useRef(false);
|
||
|
||
const tutorial = useTutorial();
|
||
|
||
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
|
||
useEffect(() => {
|
||
const isComplete = videoStatus === 'complete';
|
||
const isProcessing = videoStatus === 'generating' || videoStatus === 'polling';
|
||
|
||
if (isProcessing && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) {
|
||
tutorial.startTutorial(TUTORIAL_KEYS.GENERATING);
|
||
} else if (isComplete && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
|
||
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
|
||
}
|
||
}, [videoStatus, tutorial]);
|
||
|
||
// 소셜 미디어 포스팅 모달
|
||
const [showSocialModal, setShowSocialModal] = useState(false);
|
||
const [videoDbId, setVideoDbId] = useState<number | null>(null);
|
||
|
||
// 저장된 완료 데이터
|
||
const [songCompletionData, setSongCompletionData] = useState<{
|
||
businessName: string;
|
||
genre: string;
|
||
lyrics: string;
|
||
} | null>(null);
|
||
|
||
// 비디오 비율
|
||
const [videoRatio, setVideoRatio] = useState<'vertical' | 'horizontal'>('vertical');
|
||
|
||
useEffect(() => {
|
||
if (onVideoStatusChange) {
|
||
const mappedStatus = videoStatus === 'polling' ? 'generating' : videoStatus;
|
||
onVideoStatusChange(mappedStatus);
|
||
}
|
||
}, [videoStatus, onVideoStatusChange]);
|
||
|
||
useEffect(() => {
|
||
if (onVideoProgressChange) {
|
||
onVideoProgressChange(renderProgress);
|
||
}
|
||
}, [renderProgress, onVideoProgressChange]);
|
||
|
||
const saveToStorage = (videoTaskId: string, currentSongTaskId: string, status: VideoStatus, url: string | null, dbId?: number) => {
|
||
const data: SavedVideoState = {
|
||
videoTaskId,
|
||
songTaskId: currentSongTaskId,
|
||
status,
|
||
videoUrl: url,
|
||
videoDbId: dbId,
|
||
timestamp: Date.now(),
|
||
};
|
||
localStorage.setItem(VIDEO_STORAGE_KEY, JSON.stringify(data));
|
||
|
||
if (status === 'complete' && url) {
|
||
const completeData = {
|
||
songTaskId: currentSongTaskId,
|
||
videoUrl: url,
|
||
videoDbId: dbId,
|
||
completedAt: Date.now(),
|
||
};
|
||
localStorage.setItem(VIDEO_COMPLETE_KEY, JSON.stringify(completeData));
|
||
}
|
||
};
|
||
|
||
const clearStorage = () => {
|
||
localStorage.removeItem(VIDEO_STORAGE_KEY);
|
||
};
|
||
|
||
const loadCompleteVideo = (): { songTaskId: string; videoUrl: string; videoDbId?: number } | null => {
|
||
try {
|
||
const saved = localStorage.getItem(VIDEO_COMPLETE_KEY);
|
||
if (!saved) return null;
|
||
return JSON.parse(saved);
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
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(t('completion.requestingGeneration'));
|
||
setErrorMessage(null);
|
||
|
||
try {
|
||
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 || t('completion.generationFailed'));
|
||
}
|
||
|
||
setVideoStatus('polling');
|
||
setStatusMessage(t('completion.generatingVideo'));
|
||
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 : t('completion.generationError'));
|
||
hasStartedGeneration.current = false;
|
||
clearStorage();
|
||
}
|
||
};
|
||
|
||
const getStatusMessage = (status: string): string => {
|
||
switch (status) {
|
||
case 'planned':
|
||
return t('completion.statusPlanned');
|
||
case 'waiting':
|
||
return t('completion.statusWaiting');
|
||
case 'transcribing':
|
||
return t('completion.statusTranscribing');
|
||
case 'rendering':
|
||
return t('completion.statusRendering');
|
||
case 'succeeded':
|
||
return t('completion.statusSucceeded');
|
||
default:
|
||
return t('completion.statusDefault');
|
||
}
|
||
};
|
||
|
||
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 {
|
||
const statusResponse = await waitForVideoComplete(
|
||
videoTaskId,
|
||
(status: string) => {
|
||
setStatusMessage(getStatusMessage(status));
|
||
setRenderProgress(getProgressForStatus(status));
|
||
}
|
||
);
|
||
|
||
const videoUrlFromResponse = statusResponse.render_data?.url;
|
||
|
||
if (videoUrlFromResponse) {
|
||
const videoId = statusResponse.render_data?.video_id;
|
||
setVideoUrl(videoUrlFromResponse);
|
||
if (videoId) {
|
||
setVideoDbId(videoId);
|
||
}
|
||
setVideoStatus('complete');
|
||
setStatusMessage('');
|
||
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
|
||
} else {
|
||
throw new Error(t('completion.videoUrlMissing'));
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Video polling failed:', error);
|
||
|
||
if (error instanceof Error && error.message === 'TIMEOUT') {
|
||
setVideoStatus('error');
|
||
setErrorMessage(t('completion.generationTimeout'));
|
||
} else {
|
||
setVideoStatus('error');
|
||
setErrorMessage(error instanceof Error ? error.message : t('completion.generationError'));
|
||
}
|
||
|
||
hasStartedGeneration.current = false;
|
||
clearStorage();
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!songTaskId) return;
|
||
|
||
const completeVideo = loadCompleteVideo();
|
||
if (completeVideo && completeVideo.songTaskId === songTaskId && completeVideo.videoUrl) {
|
||
setVideoUrl(completeVideo.videoUrl);
|
||
if (completeVideo.videoDbId) setVideoDbId(completeVideo.videoDbId);
|
||
setVideoStatus('complete');
|
||
hasStartedGeneration.current = true;
|
||
return;
|
||
}
|
||
|
||
const savedState = loadFromStorage();
|
||
|
||
if (savedState && savedState.songTaskId === songTaskId) {
|
||
if (savedState.status === 'complete' && savedState.videoUrl) {
|
||
setVideoUrl(savedState.videoUrl);
|
||
if (savedState.videoDbId) setVideoDbId(savedState.videoDbId);
|
||
setVideoStatus('complete');
|
||
hasStartedGeneration.current = true;
|
||
} else if (savedState.status === 'polling') {
|
||
setVideoStatus('polling');
|
||
setStatusMessage(t('completion.processingAfterRefresh'));
|
||
hasStartedGeneration.current = true;
|
||
pollVideoStatus(savedState.videoTaskId, savedState.songTaskId);
|
||
}
|
||
} else if (!hasStartedGeneration.current) {
|
||
startVideoGeneration();
|
||
}
|
||
}, [songTaskId]);
|
||
|
||
// 완료 데이터 로드
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem('castad_song_completion');
|
||
if (saved) {
|
||
const data = JSON.parse(saved);
|
||
setSongCompletionData(data);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load song completion data:', error);
|
||
}
|
||
|
||
// 비디오 비율 로드
|
||
const savedRatio = localStorage.getItem('castad_video_ratio');
|
||
if (savedRatio === 'horizontal' || savedRatio === 'vertical') {
|
||
setVideoRatio(savedRatio);
|
||
}
|
||
}, []);
|
||
|
||
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 handleOpenSocialConnect = () => {
|
||
setShowSocialModal(true);
|
||
};
|
||
|
||
const handleCloseSocialConnect = () => {
|
||
setShowSocialModal(false);
|
||
};
|
||
|
||
const handleRetry = () => {
|
||
hasStartedGeneration.current = false;
|
||
setVideoStatus('idle');
|
||
setVideoUrl(null);
|
||
setErrorMessage(null);
|
||
clearStorage();
|
||
startVideoGeneration();
|
||
};
|
||
|
||
const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
|
||
|
||
// 비디오 해상도 계산
|
||
const getVideoResolution = () => {
|
||
const savedRatio = localStorage.getItem('castad_video_ratio');
|
||
return savedRatio === 'horizontal' ? '1280×720' : '720×1280';
|
||
};
|
||
|
||
// 파일명 생성
|
||
const getFileName = () => {
|
||
const businessName = songCompletionData?.businessName || '콘텐츠';
|
||
return `${businessName}.mp4`;
|
||
};
|
||
|
||
return (
|
||
<main className="comp2-page">
|
||
<div className="comp2-header">
|
||
<button onClick={onBack} className="comp2-back-btn">
|
||
<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>{t('completion.back')}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="comp2-title-row">
|
||
<h1 className="comp2-page-title">{t('completion.contentComplete', { defaultValue: '콘텐츠 제작 완료' })}</h1>
|
||
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc', { defaultValue: 'AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다' })}</p>
|
||
</div>
|
||
|
||
<div className="comp2-container">
|
||
<div className="comp2-grid">
|
||
{/* 왼쪽: 영상 */}
|
||
<div className="comp2-video-section">
|
||
<div className="comp2-video-wrapper">
|
||
{isLoading ? (
|
||
<div className="comp2-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="comp2-loading-text">{statusMessage}</p>
|
||
</div>
|
||
) : videoStatus === 'error' ? (
|
||
<div className="comp2-video-error">
|
||
<svg className="comp2-error-icon" 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="comp2-error-text">{errorMessage}</p>
|
||
<button onClick={handleRetry} className="comp2-retry-btn">
|
||
{t('completion.retry')}
|
||
</button>
|
||
</div>
|
||
) : videoUrl ? (
|
||
<video
|
||
src={videoUrl}
|
||
className="comp2-video-player"
|
||
controls
|
||
playsInline
|
||
/>
|
||
) : (
|
||
<div className="comp2-video-placeholder"></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 오른쪽: 콘텐츠 정보 */}
|
||
<div className="comp2-info-section">
|
||
<div className="comp2-info-header">
|
||
<span className="comp2-info-label">{t('completion.contentInfo', { defaultValue: '콘텐츠 정보' })}</span>
|
||
</div>
|
||
<div className="comp2-info-content">
|
||
<div className="comp2-file-info">
|
||
<h3 className="comp2-filename">{getFileName()}</h3>
|
||
{/* <p className="comp2-filesize">19.6MB</p> */}
|
||
</div>
|
||
<div className="comp2-meta-grid">
|
||
<div className="comp2-meta-item">
|
||
<span className="comp2-meta-label">{t('completion.genre', { defaultValue: '장르' })} : {songCompletionData?.genre || 'K-POP'}</span>
|
||
</div>
|
||
<div className="comp2-meta-divider"></div>
|
||
<div className="comp2-meta-item">
|
||
<span className="comp2-meta-label">{t('completion.resolution', { defaultValue: '규격' })} : {getVideoResolution()}</span>
|
||
</div>
|
||
<div className="comp2-meta-divider"></div>
|
||
<div className="comp2-lyrics-section">
|
||
<span className="comp2-meta-label">{t('completion.lyrics', { defaultValue: '가사' })}</span>
|
||
<p className="comp2-lyrics-text">
|
||
{songCompletionData?.lyrics || t('completion.sampleLyrics', { defaultValue: '가사를 불러오는 중...' })}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 하단 버튼 */}
|
||
<div className="comp2-buttons">
|
||
<button
|
||
onClick={handleDownload}
|
||
disabled={videoStatus !== 'complete' || !videoUrl}
|
||
className="comp2-btn comp2-btn-secondary"
|
||
>
|
||
{t('completion.download', { defaultValue: '다운로드' })}
|
||
</button>
|
||
<button
|
||
onClick={handleOpenSocialConnect}
|
||
disabled={videoStatus !== 'complete' || !videoDbId}
|
||
className="comp2-btn comp2-btn-primary"
|
||
>
|
||
{t('completion.uploadToSocial', { defaultValue: '소셜 채널 업로드' })}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */}
|
||
<SocialPostingModal
|
||
isOpen={showSocialModal}
|
||
onClose={handleCloseSocialConnect}
|
||
video={videoUrl && videoDbId ? {
|
||
video_id: videoDbId,
|
||
store_name: songCompletionData?.businessName || '',
|
||
region: '',
|
||
task_id: songTaskId || '',
|
||
result_movie_url: videoUrl,
|
||
created_at: new Date().toISOString(),
|
||
} : null}
|
||
/>
|
||
|
||
{tutorial.isActive && (
|
||
<TutorialOverlay
|
||
hints={tutorial.hints}
|
||
currentIndex={tutorial.currentHintIndex}
|
||
onNext={tutorial.nextHint}
|
||
onPrev={tutorial.prevHint}
|
||
onSkip={tutorial.skipTutorial}
|
||
groupProgress={tutorial.groupProgress}
|
||
/>
|
||
)}
|
||
</main>
|
||
);
|
||
};
|
||
|
||
export default CompletionContent;
|