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

661 lines
22 KiB
TypeScript
Executable File

import React, { useState, useRef, useEffect } 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;
onStatusChange?: (status: string) => void;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number;
}
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
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,
mId,
onStatusChange,
videoGenerationStatus = 'idle',
videoGenerationProgress = 0
}) => {
const { t } = useTranslation();
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 [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);
// 완료 데이터를 localStorage에 저장
const saveSongCompletionData = () => {
const completionData = {
businessName: businessInfo?.customer_name || '알 수 없음',
genre: selectedGenre === '자동 선택' ? 'K-POP' : selectedGenre,
lyrics: lyrics,
timestamp: Date.now(),
};
localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
};
// 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) {
// 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);
} 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(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 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 || t('soundStudio.songGenerationFailed'));
}
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 : 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;
}
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';
// 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,
orientation,
});
if (!lyricResponse.success || !lyricResponse.task_id) {
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
}
// 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);
setStatus('generating_song');
setStatusMessage(t('soundStudio.generatingSong'));
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 || 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, lyricDetailResponse.lyric_result, 0);
} catch (error) {
console.error('Music generation failed:', error);
setStatus('error');
setErrorMessage(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 (
<>
<main className="sound-studio-page">
{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>
{t('soundStudio.back')}
</button>
</div>
{/* Page Title */}
<h1 className="sound-studio-title">{t('soundStudio.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">{t('soundStudio.soundColumn')}</h3>
{/* Sound Type Selection */}
<div className="sound-studio-section">
<label className="input-label">{t('soundStudio.soundTypeLabel')}</label>
<div className="sound-type-grid">
{[
{ key: '보컬', label: t('soundStudio.soundTypeVocal') },
{ key: '배경음악', label: t('soundStudio.soundTypeBGM') },
].map(({ key, label }) => (
<button
key={key}
onClick={() => !isGenerating && setSelectedType(key)}
disabled={isGenerating}
className={`sound-type-btn ${selectedType === key ? 'active' : ''}`}
>
{label}
</button>
))}
</div>
</div>
{/* Genre Selection */}
<div className="sound-studio-section">
<label className="input-label">{t('soundStudio.genreLabel')}</label>
<div className="genre-grid">
<div className="genre-row">
{[
{ key: '자동 선택', label: t('soundStudio.genreAuto') },
{ key: 'K-POP', label: 'K-POP' },
{ key: '발라드', label: t('soundStudio.genreBallad') },
].map(({ key, label }) => (
<button
key={key}
onClick={() => setSelectedGenre(key)}
disabled={isGenerating}
className={`genre-btn ${selectedGenre === key ? 'active' : ''}`}
>
{label}
</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">{t('soundStudio.languageLabel')}</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>
{/* Generate Button / Status Message (교체) */}
{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>
) : (
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`btn-generate-sound ${isGenerating ? 'disabled' : ''}`}
>
{t('soundStudio.generateButton')}
</button>
)}
{errorMessage && (
<div className="error-message-new">
{errorMessage}
</div>
)}
</div>
{/* Right Column - Lyrics */}
<div className="lyrics-column">
<div className="lyrics-header">
<h3 className="column-title">{t('soundStudio.lyricsColumn')}</h3>
<p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</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={t('soundStudio.lyricsPlaceholder')}
/>
) : (
<div className="lyrics-placeholder">
{t('soundStudio.lyricsPlaceholder')}
</div>
)}
</div>
</div>
</div>
</div>
{/* Bottom Button */}
<div className="bottom-button-container">
<button
onClick={() => {
if (songTaskId) {
saveSongCompletionData();
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">{t('soundStudio.videoGenerating')}</span>
<div className="video-gen-progress-bar">
<div
className="video-gen-progress-fill"
style={{ width: `${videoGenerationProgress}%` }}
></div>
</div>
</>
) : (
t('soundStudio.generateVideo')
)}
</button>
</div>
</main>
</>
);
};
export default SoundStudioContent;