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

770 lines
26 KiB
TypeScript
Executable File

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<string, string> = {
'자동 선택': '', // 런타임에 랜덤 결정
'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<string, string> = {
'한국어': '🇰🇷',
'English': '🇺🇸',
'中文': '🇨🇳',
'日本語': '🇯🇵',
'ไทย': '🇹🇭',
'Tiếng Việt': '🇻🇳',
};
const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
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<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 progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(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 (
<>
<main className="sound-studio-page">
{audioUrl && (
<audio
ref={audioRef}
src={audioUrl}
preload="metadata"
onLoadedMetadata={() => {
if (audioRef.current) setDuration(audioRef.current.duration);
}}
/>
)}
{/* 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'), disabled: false },
{ key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: false },
].map(({ key, label, disabled }) => (
<button
key={key}
onClick={() => !isGenerating && !disabled && setSelectedType(key)}
disabled={isGenerating || disabled}
className={`sound-type-btn ${selectedType === key ? 'active' : ''}`}
title={disabled ? t('soundStudio.comingSoon', { defaultValue: '이후 오픈 예정입니다' }) : undefined}
>
{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', 'POP'].map(genre => (
<button
key={genre}
onClick={() => setSelectedGenre(genre)}
disabled={isGenerating}
className={`genre-btn ${selectedGenre === genre ? 'active' : ''}`}
>
{genre}
</button>
))}
</div>
</div>
</div>
{/* Language Selection */}
<div className="sound-studio-section">
<label className="input-label">{t('soundStudio.languageLabel')}</label>
<div className="genre-grid language-grid">
<div className="genre-row">
{['한국어', 'English', '中文'].map((lang) => (
<button
key={lang}
onClick={() => !isGenerating && setSelectedLang(lang)}
disabled={isGenerating}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
>
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
</button>
))}
</div>
<div className="genre-row">
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
<button
key={lang}
onClick={() => !isGenerating && setSelectedLang(lang)}
disabled={isGenerating}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
>
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
</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>
) : status === 'complete' ? (
<>
<button
onClick={handleRegenerate}
className="btn-generate-sound"
>
{t('soundStudio.regenerateButton')}
</button>
<p className="regenerate-hint">{t('soundStudio.regenerateHint')}</p>
</>
) : (
<button
onClick={handleGenerateMusic}
disabled={isGenerating}
className={`btn-generate-sound ${isGenerating ? 'disabled' : ''}`}
>
{t('soundStudio.generateButton')}
</button>
)}
{errorMessage && (() => {
const isCreditsError = errorMessage.includes('credit');
return (
<div className="error-message-new">
{isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage}
{isCreditsError && (
<a
href=""
className="charge-credits-link"
onClick={e => { e.preventDefault(); onGoToPayment?.(); }}
>
{t('soundStudio.chargeCredits')}
</a>
)}
</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 ? (() => {
const lines = lyrics.split('\n').filter(l => l.trim());
const outro = lines.slice(-1);
const body = lines.slice(0, -1);
const half = Math.ceil(body.length / 2);
const sections = [
{ tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro },
].filter(s => s.lines.length > 0);
return (
<div className="lyrics-paragraphs">
{sections.map((section, i) => (
<div key={i} className="lyrics-section">
<span className="lyrics-tag">{section.tag}</span>
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
</div>
))}
</div>
);
})() : (
<div className="lyrics-placeholder">
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : 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;