770 lines
26 KiB
TypeScript
Executable File
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;
|
|
|