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

606 lines
24 KiB
TypeScript
Executable File

import React, { useState, useRef, useEffect } from 'react';
import { generateLyric, 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: () => void;
businessInfo?: BusinessInfo;
}
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
// localStorage에 저장할 데이터 구조
interface SavedGenerationState {
taskId: string;
lyrics: string;
status: GenerationStatus;
timestamp: number;
}
const STORAGE_KEY = 'castad_song_generation';
const STORAGE_EXPIRY = 30 * 60 * 1000; // 30분
const MAX_RETRY_COUNT = 3; // 최대 재시도 횟수
const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext, businessInfo }) => {
const [selectedType, setSelectedType] = useState('보컬');
const [selectedLang, setSelectedLang] = useState('한국어');
const [selectedGenre, setSelectedGenre] = useState('AI 추천');
const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState<GenerationStatus>('idle');
const [showLyrics, setShowLyrics] = useState(false);
const [lyrics, setLyrics] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [retryCount, setRetryCount] = useState(0);
const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// localStorage에 상태 저장
const saveToStorage = (taskId: string, currentLyrics: string, currentStatus: GenerationStatus) => {
const data: SavedGenerationState = {
taskId,
lyrics: currentLyrics,
status: currentStatus,
timestamp: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
};
// localStorage에서 상태 제거
const clearStorage = () => {
localStorage.removeItem(STORAGE_KEY);
};
// localStorage에서 상태 복구
const loadFromStorage = (): SavedGenerationState | null => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return null;
const data: SavedGenerationState = JSON.parse(saved);
// 만료된 데이터인지 확인 (30분)
if (Date.now() - data.timestamp > STORAGE_EXPIRY) {
clearStorage();
return null;
}
return data;
} catch {
clearStorage();
return null;
}
};
// 컴포넌트 마운트 시 저장된 상태 복구
useEffect(() => {
const savedState = loadFromStorage();
if (savedState && (savedState.status === 'polling' || savedState.status === 'generating_song')) {
// 저장된 가사가 있으면 표시
if (savedState.lyrics) {
setLyrics(savedState.lyrics);
setShowLyrics(true);
}
// 폴링 상태 복구
setStatus('polling');
setStatusMessage('음악을 처리하고 있습니다... (새로고침 후 복구됨)');
// 폴링 재개 (저장된 가사와 함께)
resumePolling(savedState.taskId, savedState.lyrics, 0);
}
}, []);
// 폴링 재개 함수 (타임아웃 시 재생성)
const resumePolling = async (taskId: string, currentLyrics: string, currentRetryCount: number = 0) => {
try {
const downloadResponse = await waitForSongComplete(
taskId,
(pollStatus) => {
if (pollStatus === 'pending') {
setStatusMessage('대기 중...');
} else if (pollStatus === 'processing') {
setStatusMessage('음악을 처리하고 있습니다...');
}
}
);
if (!downloadResponse.success) {
throw new Error(downloadResponse.error_message || '음악 다운로드에 실패했습니다.');
}
setAudioUrl(downloadResponse.file_url);
setStatus('complete');
setStatusMessage('');
setRetryCount(0);
clearStorage();
} 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(`시간 초과로 재생성 중... (${newRetryCount}/${MAX_RETRY_COUNT})`);
// 새로운 노래 생성 요청
await regenerateSongOnly(currentLyrics, newRetryCount);
} else {
setStatus('error');
setErrorMessage('여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.');
setRetryCount(0);
clearStorage();
}
} else {
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : '음악 생성 중 오류가 발생했습니다.');
setRetryCount(0);
clearStorage();
}
}
};
// 노래만 재생성 (가사는 유지)
const regenerateSongOnly = async (currentLyrics: string, currentRetryCount: number) => {
if (!businessInfo) return;
try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const genreMap: Record<string, string> = {
'AI 추천': 'pop',
'로파이': 'lofi',
'힙합': 'hip-hop',
'어쿠스틱': 'acoustic',
};
const songResponse = await generateSong({
genre: genreMap[selectedGenre] || 'pop',
language,
lyrics: currentLyrics,
});
if (!songResponse.success) {
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
}
// 새 task_id로 저장
saveToStorage(songResponse.task_id, currentLyrics, 'polling');
// 폴링 재개
await resumePolling(songResponse.task_id, currentLyrics, currentRetryCount);
} catch (error) {
console.error('Song regeneration failed:', error);
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : '음악 재생성 중 오류가 발생했습니다.');
setRetryCount(0);
clearStorage();
}
};
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('비즈니스 정보가 없습니다. 다시 시도해주세요.');
return;
}
setStatus('generating_lyric');
setErrorMessage(null);
setStatusMessage('가사를 생성하고 있습니다...');
try {
// Step 1: Generate lyrics
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
let lyricResponse = await generateLyric({
customer_name: businessInfo.customer_name,
detail_region_info: businessInfo.detail_region_info,
language,
region: businessInfo.region,
});
// Retry if the response contains error message
let retryCount = 0;
while (lyricResponse.lyric.includes("I'm sorry") && retryCount < 3) {
retryCount++;
setStatusMessage(`가사 재생성 중... (${retryCount}/3)`);
lyricResponse = await generateLyric({
customer_name: businessInfo.customer_name,
detail_region_info: businessInfo.detail_region_info,
language,
region: businessInfo.region,
});
}
if (!lyricResponse.success) {
throw new Error(lyricResponse.error_message || '가사 생성에 실패했습니다.');
}
// 3번 재시도 후에도 여전히 에러 메시지가 포함되어 있으면 가사 카드를 표시하지 않고 에러 처리
if (lyricResponse.lyric.includes("I'm sorry")) {
throw new Error('가사 생성에 실패했습니다. 다시 시도해주세요.');
}
setLyrics(lyricResponse.lyric);
setShowLyrics(true);
// Step 2: Generate song
setStatus('generating_song');
setStatusMessage('음악을 생성하고 있습니다...');
const genreMap: Record<string, string> = {
'AI 추천': 'pop',
'로파이': 'lofi',
'힙합': 'hip-hop',
'어쿠스틱': 'acoustic',
};
const songResponse = await generateSong({
genre: genreMap[selectedGenre] || 'pop',
language,
lyrics: lyricResponse.lyric,
});
if (!songResponse.success) {
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
}
// Step 3: Poll for completion - 상태 저장
setStatus('polling');
setStatusMessage('음악을 처리하고 있습니다...');
saveToStorage(songResponse.task_id, lyricResponse.lyric, 'polling');
// 폴링 시작 (타임아웃 시 자동 재생성)
await resumePolling(songResponse.task_id, lyricResponse.lyric, 0);
} catch (error) {
console.error('Music generation failed:', error);
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : '음악 생성 중 오류가 발생했습니다.');
setRetryCount(0);
clearStorage();
}
};
const handleRegenerate = async () => {
setShowLyrics(false);
setAudioUrl(null);
setLyrics('');
setProgress(0);
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
setRetryCount(0);
clearStorage();
await handleGenerateMusic();
};
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Hidden audio element */}
{audioUrl && (
<audio ref={audioRef} src={audioUrl} preload="metadata" />
)}
<div className="flex-shrink-0 p-3 sm:p-4 md:p-6 pb-0">
<div className="flex justify-start mb-2 sm:mb-3 ml-10 md:ml-0">
<button
onClick={onBack}
className="flex items-center gap-1.5 py-1 px-3 sm:py-1.5 sm:px-4 rounded-full border border-gray-700 hover:bg-gray-800 transition-colors text-[9px] sm:text-[10px] md:text-xs text-gray-300"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
</div>
<div className="hidden sm:block text-center mb-2 md:mb-4">
<h1 className="text-lg md:text-xl lg:text-2xl font-bold mb-0.5 tracking-tight"> </h1>
<p className="text-gray-400 text-[9px] md:text-[10px] lg:text-xs font-light">
, .
</p>
</div>
</div>
<div className="flex-1 px-3 sm:px-4 md:px-6 min-h-0 overflow-hidden">
<div className="flex flex-col md:flex-row gap-2 sm:gap-3 md:gap-4 max-w-6xl mx-auto w-full justify-center h-full">
{/* Left Card: Audio Style */}
<div
className={`bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl flex flex-col transition-all duration-700 ease-out overflow-hidden ${
showLyrics ? 'md:flex-[1.5]' : 'flex-1 md:max-w-xl'
}`}
>
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-2 sm:mb-3 tracking-wide shrink-0"> </h3>
<div className="space-y-2 sm:space-y-3 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
<div>
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold">AI </p>
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{['보컬', '성우 내레이션', '배경음악'].map(type => (
<button
key={type}
onClick={() => setSelectedType(type)}
disabled={isGenerating}
className={`py-1.5 sm:py-2 rounded-lg border text-[8px] sm:text-[9px] md:text-[10px] font-bold transition-all ${selectedType === type ? 'border-[#a6ffea] bg-[#121a1d] text-[#a6ffea]' : 'border-gray-800 bg-[#121a1d]/40 text-gray-400'} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{type}
</button>
))}
</div>
</div>
<div>
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold"> </p>
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{[
{ label: '한국어', flag: '🇰🇷' },
{ label: 'English', flag: '🇺🇸' },
{ label: '中文', flag: '🇨🇳' },
{ label: '日本語', flag: '🇯🇵' },
{ label: 'ไทย', flag: '🇹🇭' },
{ label: 'Tiếng Việt', flag: '🇻🇳' }
].map(lang => (
<button
key={lang.label}
onClick={() => setSelectedLang(lang.label)}
disabled={isGenerating}
className={`py-1.5 sm:py-2 rounded-lg border text-[8px] sm:text-[9px] font-bold flex flex-col items-center gap-0.5 transition-all ${selectedLang === lang.label ? 'border-[#a6ffea] bg-[#121a1d] text-[#a6ffea]' : 'border-gray-800 bg-[#121a1d]/40 text-gray-400'} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="text-xs sm:text-sm">{lang.flag}</span>
<span>{lang.label}</span>
</button>
))}
</div>
</div>
<div>
<p className="text-[8px] sm:text-[9px] md:text-[10px] text-gray-500 mb-1.5 sm:mb-2 font-bold"> </p>
<select
value={selectedGenre}
onChange={(e) => setSelectedGenre(e.target.value)}
disabled={isGenerating}
className={`w-full py-1.5 sm:py-2 px-2 sm:px-3 rounded-lg bg-[#121a1d] border border-gray-800 text-[8px] sm:text-[9px] md:text-[10px] text-gray-300 focus:outline-none focus:border-[#a6ffea] ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<option>AI </option>
<option></option>
<option></option>
<option></option>
</select>
</div>
</div>
{/* Error Message */}
{errorMessage && (
<div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-[9px] sm:text-[10px] shrink-0">
{errorMessage}
</div>
)}
{/* Status Message */}
{isGenerating && statusMessage && (
<div className="mt-2 p-2 bg-[#a6ffea]/10 border border-[#a6ffea]/30 rounded-lg text-[#a6ffea] text-[9px] sm:text-[10px] flex items-center gap-2 shrink-0">
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
{statusMessage}
</div>
)}
<div className="mt-2 sm:mt-3 shrink-0">
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`w-full py-2 sm:py-2.5 md:py-3 font-bold rounded-xl transition-all transform active:scale-[0.98] text-[10px] sm:text-xs flex items-center justify-center gap-2 ${
isGenerating
? 'bg-[#a6ffea]/50 text-[#121a1d]/50 cursor-not-allowed'
: 'bg-[#a6ffea] text-[#121a1d] hover:bg-[#8affda]'
}`}
>
{isGenerating ? (
<>
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
...
</>
) : (
'음악 생성'
)}
</button>
</div>
</div>
{/* Right Card: Lyrics - 애니메이션으로 나타남 */}
{showLyrics && (
<div
className="bg-[#1c2a2e] rounded-xl sm:rounded-2xl md:rounded-3xl p-3 sm:p-4 md:p-5 border border-white/5 shadow-2xl flex flex-col flex-1 animate-slide-in overflow-hidden"
style={{ minWidth: '240px' }}
>
<h3 className="text-[#a6ffea] text-[9px] sm:text-[10px] md:text-xs font-bold mb-0.5 sm:mb-1 tracking-wide shrink-0"></h3>
<p className="text-gray-500 text-[8px] sm:text-[9px] mb-2 sm:mb-3 shrink-0"> </p>
<div className="flex-1 bg-[#121a1d]/50 rounded-lg sm:rounded-xl border border-white/5 p-2 sm:p-3 flex flex-col gap-2 min-h-0 overflow-hidden">
{/* Interactive Player Bar */}
<div className="bg-black/40 rounded-full p-1.5 sm:p-2 flex items-center gap-2 shrink-0">
<button
onClick={togglePlayPause}
disabled={!audioUrl}
className={`text-[#a6ffea] flex-shrink-0 transition-transform hover:scale-110 ${!audioUrl ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isPlaying ? (
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
)}
</button>
<div
ref={progressBarRef}
onMouseDown={onMouseDown}
className={`flex-1 h-1 bg-gray-800 rounded-full relative group ${audioUrl ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`}
>
<div
className="absolute left-0 top-0 h-full bg-[#a6ffea] rounded-full"
style={{ width: `${progress}%` }}
></div>
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex items-center justify-center pointer-events-none"
style={{ left: `${progress}%` }}
>
<div className="absolute w-3 h-3 sm:w-4 sm:h-4 bg-[#a6ffea] rounded-full opacity-20 animate-pulse"></div>
<div className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-[#a6ffea] rounded-full shadow-[0_0_10px_rgba(166,255,234,0.6)] border border-[#121a1d]/20"></div>
</div>
</div>
<span className="text-[8px] sm:text-[9px] text-gray-500 font-mono w-12 sm:w-14 text-right">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* Editable Lyrics */}
<textarea
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
className="flex-1 overflow-y-auto text-[9px] sm:text-[10px] text-gray-400 bg-transparent resize-none focus:outline-none focus:text-gray-300 pr-2 custom-scrollbar leading-relaxed min-h-0"
placeholder="가사가 여기에 표시됩니다..."
/>
</div>
<button
onClick={handleRegenerate}
disabled={isGenerating}
className={`mt-2 sm:mt-3 w-full py-2 sm:py-2.5 bg-[#a6ffea]/20 text-[#a6ffea] border border-[#a6ffea]/30 font-bold rounded-lg sm:rounded-xl hover:bg-[#a6ffea]/30 transition-all text-[9px] sm:text-[10px] md:text-xs shrink-0 ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
</button>
</div>
)}
</div>
</div>
<div className="flex-shrink-0 flex justify-center py-2 sm:py-3 md:py-4 px-3 sm:px-4">
<button
onClick={onNext}
disabled={status !== 'complete'}
className={`font-bold py-2 sm:py-2.5 md:py-3 px-6 sm:px-10 md:px-14 rounded-full transition-all transform active:scale-95 shadow-2xl text-[9px] sm:text-[10px] md:text-xs tracking-wide ${
status === 'complete'
? 'bg-[#a682ff] hover:bg-[#9570f0] text-white shadow-[#a682ff44]'
: 'bg-gray-600 text-gray-400 cursor-not-allowed shadow-none'
}`}
>
</button>
</div>
</div>
);
};
export default SoundStudioContent;