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

490 lines
16 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete, trackFirstVideoCreated } from '../../utils/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import CompletionView from '../../components/CompletionView';
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 [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);
// Meta FirstVideoCreated 전환 이벤트 발화 (서버가 계정당 최초 1회 판정, 실패해도 무시)
trackFirstVideoCreated();
} 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`;
};
/**
* 가사 단락 파싱.
*
* `[Verse]` 처럼 대괄호만 있는 줄을 섹션 머리로 보고, 뒤따르는 줄들을 그 섹션에
* 묶는다. 태그 없이 시작하는 가사도 있어 첫 섹션은 태그 없이 열릴 수 있다.
*/
const renderLyrics = () => {
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>
);
};
return (
<CompletionView
videoState={
isLoading
? 'loading'
: videoStatus === 'error'
? 'error'
: videoUrl
? 'ready'
: 'empty'
}
videoUrl={videoUrl}
loadingText={statusMessage}
progress={displayProgress}
errorMessage={errorMessage}
onRetry={handleRetry}
fileName={getFileName()}
metaLines={[
`${t('completion.genre')} : ${songCompletionData?.genre || 'K-POP'}`,
`${t('completion.resolution')} : ${getVideoResolution()}`,
]}
detailLabel={t('completion.lyrics')}
detail={renderLyrics()}
onDownload={handleDownload}
downloadDisabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
downloadLabel={isDownloading ? t('completion.downloading') : t('completion.download')}
onUpload={handleOpenSocialConnect}
uploadDisabled={videoStatus !== 'complete' || !videoDbId}
>
{/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */}
<SocialPostingModal
isOpen={showSocialModal}
onClose={handleCloseSocialConnect}
onGoToCalendar={onGoToCalendar}
video={
videoUrl && videoDbId
? {
// ADO2 파이프라인 완료 화면이므로 항상 영상이다
type: 'video',
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
}
/>
</CompletionView>
);
};
export default CompletionContent;