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

581 lines
20 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, 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;