581 lines
20 KiB
TypeScript
Executable File
581 lines
20 KiB
TypeScript
Executable File
|
||
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<CompletionContentProps> = ({
|
||
onBack,
|
||
songTaskId,
|
||
onVideoStatusChange,
|
||
onVideoProgressChange,
|
||
onGoToCalendar,
|
||
}) => {
|
||
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 [displayProgress, setDisplayProgress] = useState(0);
|
||
const hasStartedGeneration = useRef(false);
|
||
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | 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<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]);
|
||
|
||
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 (
|
||
<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')}</h1>
|
||
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc')}</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 className="loading-progress-wrapper">
|
||
<div className="loading-progress-bar">
|
||
<div
|
||
className="loading-progress-fill"
|
||
style={{ width: `${displayProgress}%` }}
|
||
/>
|
||
</div>
|
||
<span className="loading-progress-text">{Math.floor(displayProgress)}%</span>
|
||
</div>
|
||
</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')}</span>
|
||
</div>
|
||
<div className="comp2-file-info">
|
||
<h3 className="comp2-filename">{getFileName()}</h3>
|
||
{/* <p className="comp2-filesize">19.6MB</p> */}
|
||
</div>
|
||
<div className="comp2-info-content">
|
||
<div className="comp2-meta-grid">
|
||
<div className="comp2-meta-item">
|
||
<span className="comp2-meta-label">{t('completion.genre')} : {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')} : {getVideoResolution()}</span>
|
||
</div>
|
||
<div className="comp2-meta-divider"></div>
|
||
<div className="comp2-lyrics-section">
|
||
<span className="comp2-meta-label">{t('completion.lyrics')}</span>
|
||
{(() => {
|
||
if (!songCompletionData) {
|
||
return <p className="comp2-lyrics-text">{t('completion.sampleLyrics')}</p>;
|
||
}
|
||
if (!songCompletionData.lyrics) {
|
||
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
||
}
|
||
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 (
|
||
<div className="comp2-lyrics-paragraphs">
|
||
{sections.filter(s => s.lines.length > 0).map((section, i) => (
|
||
<div key={i} className="comp2-lyrics-para-section">
|
||
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
|
||
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 하단 버튼 */}
|
||
<div className="comp2-buttons">
|
||
<button
|
||
onClick={handleDownload}
|
||
disabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
|
||
className="comp2-btn comp2-btn-secondary"
|
||
>
|
||
{isDownloading ? t('completion.downloading') : t('completion.download')}
|
||
</button>
|
||
<button
|
||
onClick={handleOpenSocialConnect}
|
||
disabled={videoStatus !== 'complete' || !videoDbId}
|
||
className="comp2-btn comp2-btn-primary"
|
||
>
|
||
{t('completion.uploadToSocial')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */}
|
||
<SocialPostingModal
|
||
isOpen={showSocialModal}
|
||
onClose={handleCloseSocialConnect}
|
||
onGoToCalendar={onGoToCalendar}
|
||
video={videoUrl && videoDbId ? {
|
||
video_id: videoDbId,
|
||
store_name: songCompletionData?.businessName || '',
|
||
region: '',
|
||
task_id: songTaskId || '',
|
||
result_movie_url: videoUrl,
|
||
created_at: new Date().toISOString(),
|
||
like_count: 0,
|
||
comment_count: 0,
|
||
} : 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;
|