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

685 lines
23 KiB
TypeScript
Executable File

import React, { useState, useRef, useEffect } from 'react';
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;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number;
}
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
interface SavedGenerationState {
taskId: string;
songId: string;
lyrics: string;
status: GenerationStatus;
timestamp: number;
}
const STORAGE_KEY = 'castad_song_generation';
const STORAGE_EXPIRY = 30 * 60 * 1000;
const MAX_RETRY_COUNT = 3;
const LANGUAGE_FLAGS: Record<string, string> = {
'한국어': '🇰🇷',
'English': '🇺🇸',
'中文': '🇨🇳',
'日本語': '🇯🇵',
'ไทย': '🇹🇭',
'Tiếng Việt': '🇻🇳',
};
const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
onBack,
onNext,
businessInfo,
imageTaskId,
videoGenerationStatus = 'idle',
videoGenerationProgress = 0
}) => {
const [selectedType, setSelectedType] = useState('보컬');
const [selectedLang, setSelectedLang] = useState('한국어');
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
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 [songTaskId, setSongTaskId] = useState<string | null>(null);
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const languageDropdownRef = useRef<HTMLDivElement>(null);
const saveToStorage = (taskId: string, songId: string, currentLyrics: string, currentStatus: GenerationStatus) => {
const data: SavedGenerationState = {
taskId,
songId,
lyrics: currentLyrics,
status: currentStatus,
timestamp: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
};
const clearStorage = () => {
localStorage.removeItem(STORAGE_KEY);
};
const loadFromStorage = (): SavedGenerationState | null => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return null;
const data: SavedGenerationState = JSON.parse(saved);
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.songId, savedState.lyrics, 0);
}
}, []);
// Close language dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (languageDropdownRef.current && !languageDropdownRef.current.contains(event.target as Node)) {
setIsLanguageDropdownOpen(false);
}
};
if (isLanguageDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isLanguageDropdownOpen]);
// Auto-navigate to next page when video generation is complete
useEffect(() => {
if (videoGenerationStatus === 'complete' && songTaskId) {
// 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('노래를 생성하고 있습니다...');
} else if (pollStatus === 'queued') {
setStatusMessage('노래 생성 대기 중...');
}
}
);
if (!statusResponse.success) {
throw new Error(statusResponse.error_message || '음악 생성에 실패했습니다.');
}
setAudioUrl(statusResponse.song_url);
setSongTaskId(taskId);
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 || !imageTaskId) return;
try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const genreMap: Record<string, string> = {
'자동 선택': 'pop',
'K-POP': 'kpop',
'발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
const songResponse = await generateSong(imageTaskId, {
genre: genreMap[selectedGenre] || 'pop',
language,
lyrics: currentLyrics,
});
if (!songResponse.success) {
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
}
saveToStorage(songResponse.task_id, songResponse.song_id, currentLyrics, 'polling');
await resumePolling(songResponse.task_id, songResponse.song_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;
}
if (!imageTaskId) {
setErrorMessage('이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.');
return;
}
setStatus('generating_lyric');
setErrorMessage(null);
setStatusMessage('가사를 생성하고 있습니다...');
try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
// 1. 가사 생성 요청
const lyricResponse = await generateLyric({
customer_name: businessInfo.customer_name,
detail_region_info: businessInfo.detail_region_info,
language,
region: businessInfo.region,
task_id: imageTaskId,
});
if (!lyricResponse.success || !lyricResponse.task_id) {
throw new Error(lyricResponse.error_message || '가사 생성 요청에 실패했습니다.');
}
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
setStatusMessage('가사를 생성하고 있습니다...');
const lyricDetailResponse = await waitForLyricComplete(
lyricResponse.task_id,
(status: string) => {
if (status === 'processing') {
setStatusMessage('가사를 생성하고 있습니다...');
}
}
);
if (!lyricDetailResponse.lyric_result) {
throw new Error('가사를 받지 못했습니다.');
}
// "I'm sorry" 체크
if (lyricDetailResponse.lyric_result.includes("I'm sorry")) {
throw new Error('가사 생성에 실패했습니다. 다시 시도해주세요.');
}
setLyrics(lyricDetailResponse.lyric_result);
setShowLyrics(true);
setStatus('generating_song');
setStatusMessage('노래를 생성하고 있습니다...');
const genreMap: Record<string, string> = {
'자동 선택': 'pop',
'K-POP': 'kpop',
'발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
const songResponse = await generateSong(imageTaskId, {
genre: genreMap[selectedGenre] || 'pop',
language,
lyrics: lyricDetailResponse.lyric_result,
});
if (!songResponse.success) {
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
}
setStatus('polling');
setStatusMessage('노래를 생성하고 있습니다...');
saveToStorage(songResponse.task_id, songResponse.song_id, lyricDetailResponse.lyric_result, 'polling');
await resumePolling(songResponse.task_id, songResponse.song_id, lyricDetailResponse.lyric_result, 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 (
<main className="page-container">
{audioUrl && (
<audio ref={audioRef} src={audioUrl} preload="metadata" />
)}
{/* Header */}
<div className="sound-studio-header">
<button onClick={onBack} className="btn-back-new">
<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>
</button>
</div>
{/* Page Title */}
<h1 className="sound-studio-title"> </h1>
{/* Main Content - Two Column Layout */}
<div className="sound-studio-container">
<div className="sound-studio-columns">
{/* Left Column - Sound Settings */}
<div className="sound-column">
<h3 className="column-title"></h3>
{/* Sound Type Selection */}
<div className="sound-studio-section">
<label className="input-label">AI </label>
<div className="sound-type-grid">
{['보컬', '배경음악', '성우 내레이션'].map(type => {
const isDisabled = type === '성우 내레이션' || isGenerating;
return (
<button
key={type}
onClick={() => !isDisabled && setSelectedType(type)}
disabled={isDisabled}
className={`sound-type-btn ${selectedType === type ? 'active' : ''} ${type === '성우 내레이션' ? 'permanently-disabled' : ''}`}
>
{type}
</button>
);
})}
</div>
</div>
{/* Genre Selection */}
<div className="sound-studio-section">
<label className="input-label"> </label>
<div className="genre-grid">
<div className="genre-row">
{['자동 선택', 'K-POP', '발라드'].map(genre => (
<button
key={genre}
onClick={() => setSelectedGenre(genre)}
disabled={isGenerating}
className={`genre-btn ${selectedGenre === genre ? 'active' : ''}`}
>
{genre}
</button>
))}
</div>
<div className="genre-row">
{['Hip-Hop', 'R&B', 'EDM'].map(genre => (
<button
key={genre}
onClick={() => setSelectedGenre(genre)}
disabled={isGenerating}
className={`genre-btn ${selectedGenre === genre ? 'active' : ''}`}
>
{genre}
</button>
))}
</div>
<div className="genre-row">
{['JAZZ', 'ROCK'].map(genre => (
<button
key={genre}
onClick={() => setSelectedGenre(genre)}
disabled={isGenerating}
className={`genre-btn ${selectedGenre === genre ? 'active' : ''}`}
>
{genre}
</button>
))}
<div className="genre-btn-placeholder"></div>
</div>
</div>
</div>
{/* Language Selection */}
<div className="sound-studio-section">
<label className="input-label"></label>
<div className="language-selector-wrapper" ref={languageDropdownRef}>
<button
onClick={() => setIsLanguageDropdownOpen(!isLanguageDropdownOpen)}
disabled={isGenerating}
className="language-selector"
>
<div className="language-display">
<span className="language-flag">{LANGUAGE_FLAGS[selectedLang]}</span>
<span className="language-name">{selectedLang}</span>
</div>
<img
src="/assets/images/icon-dropdown.svg"
alt=""
className={`language-dropdown-icon ${isLanguageDropdownOpen ? 'open' : ''}`}
/>
</button>
{isLanguageDropdownOpen && (
<div className="language-dropdown-menu">
{Object.keys(LANGUAGE_MAP).map((lang) => (
<button
key={lang}
onClick={() => {
setSelectedLang(lang);
setIsLanguageDropdownOpen(false);
}}
className={`language-dropdown-item ${selectedLang === lang ? 'active' : ''}`}
>
<span className="language-flag">{LANGUAGE_FLAGS[lang]}</span>
<span className="language-name">{lang}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
{/* Right Column - Lyrics */}
<div className="lyrics-column">
<div className="lyrics-header">
<h3 className="column-title"></h3>
<p className="lyrics-subtitle"> </p>
</div>
{/* Audio Player */}
<div className="audio-player">
<button
onClick={togglePlayPause}
disabled={!audioUrl}
className={`play-btn-new ${!audioUrl ? 'disabled' : ''}`}
>
{isPlaying ? (
<svg className="w-6 h-6" 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-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</button>
<div
ref={progressBarRef}
onMouseDown={onMouseDown}
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
>
<div
className="audio-progress-fill"
style={{ width: `${progress}%` }}
></div>
</div>
<span className="audio-time">
{formatTime(Math.max(0, duration - currentTime))}
</span>
</div>
{/* Lyrics Display */}
<div className="lyrics-display">
{lyrics ? (
<textarea
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
className="lyrics-textarea"
placeholder="사운드 생성 시 가사 표시됩니다."
/>
) : (
<div className="lyrics-placeholder">
.
</div>
)}
</div>
</div>
</div>
{/* Generate Button */}
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`btn-generate-sound ${isGenerating ? 'disabled' : ''}`}
>
{isGenerating ? (
<>
<svg className="animate-spin h-4 w-4" 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>
{errorMessage && (
<div className="error-message-new">
{errorMessage}
</div>
)}
{isGenerating && statusMessage && (
<div className="status-message-new">
<svg className="animate-spin h-4 w-4" 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>
{/* Bottom Button */}
<div className="bottom-button-container">
<button
onClick={() => songTaskId && onNext(songTaskId)}
disabled={status !== 'complete' || !songTaskId || videoGenerationStatus === 'generating'}
className={`btn-video-generate ${
videoGenerationStatus === 'generating'
? 'generating'
: status !== 'complete' || !songTaskId
? 'disabled'
: ''
}`}
>
{videoGenerationStatus === 'generating' ? (
<>
<span className="video-gen-text"> </span>
<div className="video-gen-progress-bar">
<div
className="video-gen-progress-fill"
style={{ width: `${videoGenerationProgress}%` }}
></div>
</div>
</>
) : (
'영상 생성하기'
)}
</button>
</div>
</main>
);
};
export default SoundStudioContent;