import React, { useState, useRef, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } 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'; }; // 실제 영상 생성에 사용된 genre 값(kpop, hip-hop 등)을 화면 표시용 라벨로 변환 const getGenreDisplayLabel = (genreValue: string, t: (key: string) => string): string => { const displayMap: Record = { kpop: 'K-POP', pop: 'POP', ballad: t('soundStudio.genreBallad'), 'hip-hop': 'Hip-Hop', rnb: 'R&B', edm: 'EDM', jazz: 'JAZZ', rock: 'ROCK', }; return displayMap[genreValue] || genreValue; }; 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 [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 audioRef = useRef(null); // 완료 데이터를 localStorage에 저장 const saveSongCompletionData = () => { const completionData = { businessName: businessInfo?.customer_name || '알 수 없음', // '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장 genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : 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로 복원 (public Blob URL이라 만료되지 않음) // 백엔드에 /song/download 엔드포인트가 없으므로 재조회하지 않는다. setSongTaskId(parsed.songTaskId); setAudioUrl(parsed.audioUrl); setLyrics(parsed.lyrics ?? ''); setStatus('complete'); } 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); } }; 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 === '배경음악'; // 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다. // '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다. const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre]; // 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 }), ...(manualGenre && { genre: manualGenre }), }); if (!lyricResponse.success || !lyricResponse.task_id) { throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed')); } let songLyrics: string | undefined; // BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다. // 이 경우에만 기존 랜덤 선택 로직으로 폴백한다. let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre); 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; // 가사와 함께 확정된 장르(수동 선택값 또는 GPT 추천값)를 그대로 음악 생성에 사용 effectiveGenre = lyricDetailResponse.genre || effectiveGenre; } setStatus('generating_song'); setStatusMessage(t('soundStudio.generatingSong')); 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'; // 드래그로 인한 잦은 리렌더에서도 가사 파싱(정규식/배열 처리)이 매번 재실행되지 않도록 메모이제이션 const lyricsSections = useMemo(() => { if (!lyrics) return []; const lines = lyrics.split('\n').filter(l => l.trim()); const sections: { tag: string | null; lines: string[] }[] = []; lines.forEach(line => { 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 sections.filter(s => s.lines.length > 0); }, [lyrics]); // status 변경 시 부모에 알림 useEffect(() => { onStatusChange?.(status); }, [status]); return ( <>
{audioUrl && (
); }; export default SoundStudioContent;