685 lines
23 KiB
TypeScript
Executable File
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;
|