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

474 lines
16 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;