import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete } 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; onGoToCalendar?: () => 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 = ({ onBack, songTaskId, onVideoStatusChange, onVideoProgressChange, onGoToCalendar, }) => { const { t } = useTranslation(); const [videoStatus, setVideoStatus] = useState('idle'); const [videoUrl, setVideoUrl] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [statusMessage, setStatusMessage] = useState(''); const [renderProgress, setRenderProgress] = useState(0); const [displayProgress, setDisplayProgress] = useState(0); const hasStartedGeneration = useRef(false); const displayIntervalRef = useRef | null>(null); const tutorial = useTutorial(); const tutorialIsActiveRef = useRef(tutorial.isActive); tutorialIsActiveRef.current = tutorial.isActive; // 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동) useEffect(() => { const isComplete = videoStatus === 'complete'; const isProcessing = videoStatus === 'generating' || videoStatus === 'polling'; if (isProcessing && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) { tutorial.startTutorial(TUTORIAL_KEYS.GENERATING); } else if (isComplete && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) { tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION); } }, [videoStatus]); // 소셜 미디어 포스팅 모달 const [showSocialModal, setShowSocialModal] = useState(false); const [videoDbId, setVideoDbId] = useState(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]); useEffect(() => { if (displayIntervalRef.current) clearInterval(displayIntervalRef.current); if (renderProgress === 100) { setDisplayProgress(100); return; } // renderProgress에 도달한 후에도 99%까지 서서히 크리핑 const CREEP_MAX = 99; displayIntervalRef.current = setInterval(() => { setDisplayProgress(prev => { const target = Math.max(prev, renderProgress); if (prev >= CREEP_MAX) return prev; const increment = prev < 70 ? 0.2 : prev < 90 ? 0.04 : 0.01; return Math.min(prev + increment, Math.max(target, Math.min(prev + increment, CREEP_MAX))); }); }, 100); return () => { if (displayIntervalRef.current) clearInterval(displayIntervalRef.current); }; }, [renderProgress]); 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.checkingSubtitle')); setErrorMessage(null); try { // 자막 완료 여부 확인 후 미완료면 폴링 const subtitleStatus = await getSubtitleStatus(songTaskId); if (subtitleStatus.status !== 'completed') { setStatusMessage(t('completion.waitingSubtitle')); await waitForSubtitleComplete(songTaskId); } setStatusMessage(t('completion.requestingGeneration')); 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'); setShowComplete(true); 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'); setShowComplete(true); 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 [isDownloading, setIsDownloading] = useState(false); const handleDownload = async () => { if (!videoUrl || isDownloading) return; setIsDownloading(true); try { const response = await fetch(videoUrl); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; link.download = getFileName(); document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch { const link = document.createElement('a'); link.href = videoUrl; link.download = getFileName(); link.target = '_blank'; document.body.appendChild(link); link.click(); document.body.removeChild(link); } finally { setIsDownloading(false); } }; const handleOpenSocialConnect = () => { setShowSocialModal(true); }; const handleCloseSocialConnect = () => { setShowSocialModal(false); }; const handleRetry = () => { hasStartedGeneration.current = false; setVideoStatus('idle'); setVideoUrl(null); setErrorMessage(null); setShowComplete(false); clearStorage(); startVideoGeneration(); }; const [showComplete, setShowComplete] = useState(false); useEffect(() => { if (displayProgress < 100) return; const timer = setTimeout(() => setShowComplete(true), 500); return () => clearTimeout(timer); }, [displayProgress]); const isLoading = videoStatus === 'generating' || videoStatus === 'polling' || (videoStatus === 'complete' && !showComplete); // 비디오 해상도 계산 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 (
{/*
*/}

{t('completion.contentComplete')}

{t('completion.contentCompleteDesc')}

{/* 왼쪽: 영상 */}
{isLoading ? (

{statusMessage}

{Math.floor(displayProgress)}%
) : videoStatus === 'error' ? (

{errorMessage}

) : videoUrl ? (
{/* 오른쪽: 콘텐츠 정보 */}
{t('completion.contentInfo')}

{getFileName()}

{/*

19.6MB

*/}
{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}
{t('completion.resolution')} : {getVideoResolution()}
{t('completion.lyrics')} {(() => { if (!songCompletionData) { return

{t('completion.sampleLyrics')}

; } if (!songCompletionData.lyrics) { return

{t('completion.noLyricsBGM')}

; } const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim()); const sections: { tag: string | null; lines: string[] }[] = []; lines.forEach((line: string) => { const tagMatch = line.trim().match(/^\[(.+)\]$/); if (tagMatch) { sections.push({ tag: `[${tagMatch[1]}]`, lines: [] }); } else if (sections.length === 0) { sections.push({ tag: null, lines: [line] }); } else { sections[sections.length - 1].lines.push(line); } }); return (
{sections.filter(s => s.lines.length > 0).map((section, i) => (
{section.tag && {section.tag}}

{section.lines.join('\n')}

))}
); })()}
{/* 하단 버튼 */}
{/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */} {tutorial.isActive && ( )}
); }; export default CompletionContent;