import React, { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete, downloadSong } from '../../utils/api'; import { LANGUAGE_MAP } from '../../types/api'; interface BusinessInfo { customer_name: string; region: string; detail_region_info: string; } interface SoundStudioContentProps { onBack: () => void; onNext: (songTaskId: string) => void; businessInfo?: BusinessInfo; imageTaskId: string | null; mId: number; industry?: string; onStatusChange?: (status: string) => void; videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error'; videoGenerationProgress?: number; onGoToPayment?: () => void; } type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error'; const MAX_RETRY_COUNT = 3; // 생성 완료된 곡 상태를 새로고침 후에도 복원하기 위한 키 // GenerationFlow의 clearAllProjectStorage가 이 키를 포함해 정리함 const SONG_GENERATION_KEY = 'castad_song_generation'; const GENRE_VALUES = ['kpop', 'pop', 'ballad', 'hip-hop', 'rnb', 'edm', 'jazz', 'rock']; const genreMap: Record = { '자동 선택': '', // 런타임에 랜덤 결정 'K-POP': 'kpop', 'POP': 'pop', '발라드': 'ballad', 'Hip-Hop': 'hip-hop', 'R&B': 'rnb', 'EDM': 'edm', 'JAZZ': 'jazz', 'ROCK': 'rock', }; const getEffectiveGenre = (selectedGenre: string): string => { if (selectedGenre === '자동 선택') { return GENRE_VALUES[Math.floor(Math.random() * GENRE_VALUES.length)]; } return genreMap[selectedGenre] || 'pop'; }; const sanitizeError = (message: string, fallback: string): string => { if ( message.includes('Traceback') || message.includes('File "/') || message.includes('httpx.') || message.includes('httpcore.') || message.length > 200 ) { return fallback; } return message; }; const LANGUAGE_FLAGS: Record = { '한국어': '🇰🇷', 'English': '🇺🇸', '中文': '🇨🇳', '日本語': '🇯🇵', 'ไทย': '🇹🇭', 'Tiếng Việt': '🇻🇳', }; const SoundStudioContent: React.FC = ({ onBack, onNext, businessInfo, imageTaskId, mId, industry = '', onStatusChange, videoGenerationStatus = 'idle', videoGenerationProgress = 0, onGoToPayment, }) => { const { t } = useTranslation(); const [selectedType, setSelectedType] = useState('보컬'); const [selectedLang, setSelectedLang] = useState('한국어'); const [selectedGenre, setSelectedGenre] = useState('자동 선택'); const [usedGenre, setUsedGenre] = useState(''); const [progress, setProgress] = useState(0); const [isDragging, setIsDragging] = useState(false); const [status, setStatus] = useState('idle'); const [lyrics, setLyrics] = useState(''); const [audioUrl, setAudioUrl] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [errorMessage, setErrorMessage] = useState(null); const [statusMessage, setStatusMessage] = useState(''); const [retryCount, setRetryCount] = useState(0); const [songTaskId, setSongTaskId] = useState(null); const progressBarRef = useRef(null); const audioRef = useRef(null); // 완료 데이터를 localStorage에 저장 const saveSongCompletionData = () => { const completionData = { businessName: businessInfo?.customer_name || '알 수 없음', genre: selectedGenre, lyrics: lyrics, timestamp: Date.now(), }; localStorage.setItem('castad_song_completion', JSON.stringify(completionData)); }; // 새로고침 후 생성 완료된 곡 상태 복원 useEffect(() => { const saved = localStorage.getItem(SONG_GENERATION_KEY); if (!saved) return; try { const parsed = JSON.parse(saved); if (parsed.status !== 'complete' || !parsed.audioUrl || !parsed.songTaskId) return; // 저장된 URL로 일단 복원 후, 서버에서 최신 URL 재조회 시도 setSongTaskId(parsed.songTaskId); setAudioUrl(parsed.audioUrl); setLyrics(parsed.lyrics ?? ''); setStatus('complete'); // downloadSong으로 최신 URL을 재조회해 갱신 (만료 방지) downloadSong(parsed.songTaskId) .then((res) => { if (res.success && res.song_result_url) { setAudioUrl(res.song_result_url); // 저장값도 갱신 localStorage.setItem(SONG_GENERATION_KEY, JSON.stringify({ ...parsed, audioUrl: res.song_result_url, })); } }) .catch(() => { // 재조회 실패 시 저장된 URL 그대로 사용 }); } catch { // 파싱 실패 시 무시 } }, []); // 마운트 1회만 실행 // Close language dropdown when clicking outside // Auto-navigate to next page when video generation is complete useEffect(() => { if (videoGenerationStatus === 'complete' && songTaskId) { // Save completion data before navigating saveSongCompletionData(); // Wait a brief moment to show 100% completion before navigating const timer = setTimeout(() => { onNext(songTaskId); }, 500); return () => clearTimeout(timer); } }, [videoGenerationStatus, songTaskId, onNext]); const resumePolling = async (taskId: string, songId: string, currentLyrics: string, currentRetryCount: number = 0) => { try { const statusResponse = await waitForSongComplete( songId, (pollStatus: string) => { if (pollStatus === 'streaming') { setStatusMessage(t('soundStudio.generatingSong')); } else if (pollStatus === 'queued') { setStatusMessage(t('soundStudio.songQueued')); } } ); if (!statusResponse.success) { throw new Error(statusResponse.error_message || t('soundStudio.musicGenerationFailed')); } // song_result_url을 사용하여 재생 setSongTaskId(taskId); setAudioUrl(statusResponse.song_result_url); setStatus('complete'); setStatusMessage(''); setRetryCount(0); // 새로고침 후에도 복원할 수 있도록 완료된 곡 상태를 저장 try { localStorage.setItem(SONG_GENERATION_KEY, JSON.stringify({ audioUrl: statusResponse.song_result_url, songTaskId: taskId, lyrics: currentLyrics, status: 'complete', })); } catch { // localStorage 쓰기 실패는 무시 } } catch (error) { console.error('Polling failed:', error); if (error instanceof Error && error.message === 'TIMEOUT') { if (currentRetryCount < MAX_RETRY_COUNT) { const newRetryCount = currentRetryCount + 1; setRetryCount(newRetryCount); setStatusMessage(t('soundStudio.retryMessage', { count: newRetryCount, max: MAX_RETRY_COUNT })); await regenerateSongOnly(currentLyrics, newRetryCount); } else { setStatus('error'); setErrorMessage(t('soundStudio.multipleRetryFailed')); setRetryCount(0); } } else { setStatus('error'); setErrorMessage(sanitizeError( error instanceof Error ? error.message : '', t('soundStudio.musicGenerationError') )); setRetryCount(0); } } }; const regenerateSongOnly = async (currentLyrics: string, currentRetryCount: number) => { if (!businessInfo || !imageTaskId) return; try { const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const isInstrumental = selectedType === '배경음악'; const effectiveGenre = getEffectiveGenre(selectedGenre); setUsedGenre(effectiveGenre); const songResponse = await generateSong(imageTaskId, { genre: effectiveGenre, ...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }), }); if (!songResponse.success) { throw new Error(songResponse.error_message || t('soundStudio.songGenerationFailed')); } await resumePolling(songResponse.task_id, songResponse.song_id, currentLyrics, currentRetryCount); } catch (error) { console.error('Song regeneration failed:', error); setStatus('error'); setErrorMessage(sanitizeError( error instanceof Error ? error.message : '', t('soundStudio.songRegenerationError') )); setRetryCount(0); } }; const handleMove = (clientX: number) => { if (!progressBarRef.current || !audioRef.current) return; const rect = progressBarRef.current.getBoundingClientRect(); const newProgress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100)); const newTime = (newProgress / 100) * duration; audioRef.current.currentTime = newTime; setProgress(newProgress); setCurrentTime(newTime); }; const onMouseDown = (e: React.MouseEvent) => { if (!audioUrl) return; setIsDragging(true); handleMove(e.clientX); }; useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (isDragging) handleMove(e.clientX); }; const onMouseUp = () => { setIsDragging(false); }; if (isDragging) { window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); } return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; }, [isDragging]); useEffect(() => { const audio = audioRef.current; if (!audio) return; const handleTimeUpdate = () => { setCurrentTime(audio.currentTime); if (audio.duration > 0) { setProgress((audio.currentTime / audio.duration) * 100); } }; const handleLoadedMetadata = () => { setDuration(audio.duration); }; const handleEnded = () => { setIsPlaying(false); setProgress(0); setCurrentTime(0); }; audio.addEventListener('timeupdate', handleTimeUpdate); audio.addEventListener('loadedmetadata', handleLoadedMetadata); audio.addEventListener('ended', handleEnded); return () => { audio.removeEventListener('timeupdate', handleTimeUpdate); audio.removeEventListener('loadedmetadata', handleLoadedMetadata); audio.removeEventListener('ended', handleEnded); }; }, [audioUrl]); const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; const togglePlayPause = () => { if (!audioRef.current) return; if (isPlaying) { audioRef.current.pause(); } else { audioRef.current.play(); } setIsPlaying(!isPlaying); }; const handleGenerateMusic = async () => { if (!businessInfo) { setErrorMessage(t('soundStudio.noBusinessInfo')); return; } if (!imageTaskId) { setErrorMessage(t('soundStudio.noImageUploadInfo')); return; } // 새 곡 생성 시작 — 이전에 저장된 완료 곡 제거 localStorage.removeItem(SONG_GENERATION_KEY); setStatus('generating_lyric'); setErrorMessage(null); setStatusMessage(t('soundStudio.generatingLyrics')); const savedRatio = localStorage.getItem('castad_video_ratio'); const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical'; try { const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const isInstrumental = selectedType === '배경음악'; // 1. 가사 생성 요청 console.log('[SoundStudio] Sending m_id:', mId); const lyricResponse = await generateLyric({ customer_name: businessInfo.customer_name, detail_region_info: businessInfo.detail_region_info, language, m_id: mId, region: businessInfo.region, task_id: imageTaskId, industry, orientation, ...(isInstrumental && { instrumental: true }), }); if (!lyricResponse.success || !lyricResponse.task_id) { throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed')); } let songLyrics: string | undefined; if (!isInstrumental) { // 2. 가사 생성 상태 폴링 → 완료 시 상세 조회 setStatusMessage(t('soundStudio.generatingLyrics')); const lyricDetailResponse = await waitForLyricComplete( lyricResponse.task_id, (status: string) => { if (status === 'processing') { setStatusMessage(t('soundStudio.generatingLyrics')); } } ); if (!lyricDetailResponse.lyric_result) { throw new Error(t('soundStudio.lyricNotReceived')); } // "I'm sorry" 체크 if (lyricDetailResponse.lyric_result.includes("I'm sorry")) { throw new Error(t('soundStudio.lyricGenerationError')); } setLyrics(lyricDetailResponse.lyric_result); songLyrics = lyricDetailResponse.lyric_result; } setStatus('generating_song'); setStatusMessage(t('soundStudio.generatingSong')); const effectiveGenre = getEffectiveGenre(selectedGenre); setUsedGenre(effectiveGenre); const songResponse = await generateSong(imageTaskId, { genre: effectiveGenre, ...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }), }); if (!songResponse.success) { throw new Error(songResponse.error_message || t('soundStudio.songGenerationFailed')); } // 디버깅: songResponse 확인 console.log('songResponse:', songResponse); console.log('song_id:', songResponse.song_id); console.log('task_id:', songResponse.task_id); if (!songResponse.song_id) { throw new Error(t('soundStudio.songIdMissing')); } setStatus('polling'); setStatusMessage(t('soundStudio.generatingSong')); await resumePolling(songResponse.task_id, songResponse.song_id, songLyrics ?? '', 0); } catch (error) { console.error('Music generation failed:', error); setStatus('error'); setErrorMessage(sanitizeError( error instanceof Error ? error.message : '', t('soundStudio.musicGenerationError') )); setRetryCount(0); } }; const handleRegenerate = async () => { setAudioUrl(null); setLyrics(''); setProgress(0); setCurrentTime(0); setDuration(0); setIsPlaying(false); setRetryCount(0); await handleGenerateMusic(); }; const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling'; // status 변경 시 부모에 알림 useEffect(() => { onStatusChange?.(status); }, [status]); return ( <>
{audioUrl && (
); }; export default SoundStudioContent;