From 24d61f925addb524c8e72dd575d45d5cbd2f7d51 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 29 Dec 2025 17:56:57 +0900 Subject: [PATCH] =?UTF-8?q?front=20=EC=9E=91=EC=97=85=20=EC=A4=91=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- index.css | 41 +++ .../Dashboard/AssetManagementContent.tsx | 49 ++- src/pages/Dashboard/CompletionContent.tsx | 317 ++++++++++++++++-- src/pages/Dashboard/GenerationFlow.tsx | 49 ++- src/pages/Dashboard/SoundStudioContent.tsx | 86 +++-- src/types/api.ts | 96 +++++- src/utils/api.ts | 281 +++++++++++++--- 8 files changed, 797 insertions(+), 125 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fb54b9f..1bd5633 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(mkdir:*)", "Bash(mv:*)", "Bash(rmdir:*)", - "Bash(rm:*)" + "Bash(rm:*)", + "Bash(npm run build:*)" ] } } diff --git a/index.css b/index.css index 6e0f971..4c86657 100644 --- a/index.css +++ b/index.css @@ -1184,6 +1184,47 @@ background-color: var(--color-mint); } +/* Video Player */ +.video-player { + width: 100%; + height: 100%; + object-fit: contain; + cursor: pointer; +} + +/* Video Loading State */ +.video-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; +} + +/* Video Error State */ +.video-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; +} + +.video-error svg { + width: 4rem; + height: 4rem; + color: #ef4444; + margin-bottom: 1rem; +} + +/* Social Card Disabled State */ +.social-card.disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ===================================================== Tags Container ===================================================== */ diff --git a/src/pages/Dashboard/AssetManagementContent.tsx b/src/pages/Dashboard/AssetManagementContent.tsx index 18c9b2e..8eb14c3 100755 --- a/src/pages/Dashboard/AssetManagementContent.tsx +++ b/src/pages/Dashboard/AssetManagementContent.tsx @@ -1,10 +1,11 @@ -import React, { useRef } from 'react'; -import { ImageItem } from '../../types/api'; +import React, { useRef, useState } from 'react'; +import { ImageItem, ImageUrlItem } from '../../types/api'; +import { uploadImages } from '../../utils/api'; interface AssetManagementContentProps { onBack: () => void; - onNext: () => void; + onNext: (imageTaskId: string) => void; imageList: ImageItem[]; onRemoveImage: (index: number) => void; onAddImages: (files: File[]) => void; @@ -19,11 +20,42 @@ const AssetManagementContent: React.FC = ({ }) => { const fileInputRef = useRef(null); const imageListRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); const getImageSrc = (item: ImageItem): string => { return item.type === 'url' ? item.url : item.preview; }; + const handleNextWithUpload = async () => { + if (imageList.length === 0) return; + + setIsUploading(true); + setUploadError(null); + + try { + // URL 이미지와 파일 이미지 분리 + const urlImages: ImageUrlItem[] = imageList + .filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url') + .map((item) => ({ url: item.url })); + + const fileImages: File[] = imageList + .filter((item: ImageItem): item is ImageItem & { type: 'file' } => item.type === 'file') + .map((item) => item.file); + + // 이미지가 하나라도 있으면 업로드 + if (urlImages.length > 0 || fileImages.length > 0) { + const response = await uploadImages(urlImages, fileImages); + onNext(response.task_id); + } + } catch (error) { + console.error('Image upload failed:', error); + setUploadError(error instanceof Error ? error.message : '이미지 업로드에 실패했습니다.'); + } finally { + setIsUploading(false); + } + }; + const handleImageListWheel = (e: React.WheelEvent) => { const el = imageListRef.current; if (!el) return; @@ -172,12 +204,15 @@ const AssetManagementContent: React.FC = ({ {/* Bottom Button */}
+ {uploadError && ( +

{uploadError}

+ )}
diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index 0b0aa32..aa93294 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -1,12 +1,180 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import { generateVideo, waitForVideoComplete } from '../../utils/api'; interface CompletionContentProps { onBack: () => void; + songTaskId: string | null; } -const CompletionContent: React.FC = ({ onBack }) => { +type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error'; + +const VIDEO_STORAGE_KEY = 'castad_video_generation'; +const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000; // 30분 + +interface SavedVideoState { + videoTaskId: string; + songTaskId: string; + status: VideoStatus; + videoUrl: string | null; + timestamp: number; +} + +const CompletionContent: React.FC = ({ onBack, songTaskId }) => { const [selectedSocials, setSelectedSocials] = useState([]); + const [videoStatus, setVideoStatus] = useState('idle'); + const [videoUrl, setVideoUrl] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [statusMessage, setStatusMessage] = useState(''); + const [isPlaying, setIsPlaying] = useState(false); + const [progress, setProgress] = useState(0); + const videoRef = useRef(null); + const hasStartedGeneration = useRef(false); + + const saveToStorage = (videoTaskId: string, currentSongTaskId: string, status: VideoStatus, url: string | null) => { + const data: SavedVideoState = { + videoTaskId, + songTaskId: currentSongTaskId, + status, + videoUrl: url, + timestamp: Date.now(), + }; + localStorage.setItem(VIDEO_STORAGE_KEY, JSON.stringify(data)); + }; + + const clearStorage = () => { + localStorage.removeItem(VIDEO_STORAGE_KEY); + }; + + const loadFromStorage = (): SavedVideoState | null => { + try { + const saved = localStorage.getItem(VIDEO_STORAGE_KEY); + if (!saved) return null; + + const data: SavedVideoState = JSON.parse(saved); + + if (Date.now() - data.timestamp > VIDEO_STORAGE_EXPIRY) { + clearStorage(); + return null; + } + + return data; + } catch { + clearStorage(); + return null; + } + }; + + const startVideoGeneration = async () => { + if (!songTaskId || hasStartedGeneration.current) return; + + hasStartedGeneration.current = true; + setVideoStatus('generating'); + setStatusMessage('영상 생성을 요청하고 있습니다...'); + setErrorMessage(null); + + try { + const videoResponse = await generateVideo(songTaskId); + + if (!videoResponse.success) { + throw new Error(videoResponse.error_message || '영상 생성 요청에 실패했습니다.'); + } + + setVideoStatus('polling'); + setStatusMessage('영상을 생성하고 있습니다...'); + // video/status API는 creatomate_render_id를 사용 + saveToStorage(videoResponse.creatomate_render_id, songTaskId, 'polling', null); + + await pollVideoStatus(videoResponse.creatomate_render_id, songTaskId); + + } catch (error) { + console.error('Video generation failed:', error); + setVideoStatus('error'); + setErrorMessage(error instanceof Error ? error.message : '영상 생성 중 오류가 발생했습니다.'); + hasStartedGeneration.current = false; + clearStorage(); + } + }; + + // 상태별 한글 메시지 매핑 + const getStatusMessage = (status: string): string => { + switch (status) { + case 'planned': + return '예약됨'; + case 'waiting': + return '대기 중'; + case 'transcribing': + return '트랜스크립션 중'; + case 'rendering': + return '렌더링 중'; + case 'succeeded': + return '완료'; + default: + return '처리 중...'; + } + }; + + const pollVideoStatus = async (videoTaskId: string, currentSongTaskId: string) => { + try { + // 영상 생성 상태 폴링 (3분 타임아웃, 3초 간격) + const statusResponse = await waitForVideoComplete( + videoTaskId, + (status: string) => { + setStatusMessage(getStatusMessage(status)); + } + ); + + // render_data.url에서 영상 URL 가져오기 + const videoUrlFromResponse = statusResponse.render_data?.url; + + if (videoUrlFromResponse) { + setVideoUrl(videoUrlFromResponse); + setVideoStatus('complete'); + setStatusMessage(''); + saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse); + } else { + throw new Error('영상 URL을 받지 못했습니다.'); + } + + } catch (error) { + console.error('Video polling failed:', error); + + if (error instanceof Error && error.message === 'TIMEOUT') { + setVideoStatus('error'); + setErrorMessage('영상 생성 시간이 초과되었습니다. 다시 시도해주세요.'); + } else { + setVideoStatus('error'); + setErrorMessage(error instanceof Error ? error.message : '영상 생성 중 오류가 발생했습니다.'); + } + + hasStartedGeneration.current = false; + clearStorage(); + } + }; + + // 컴포넌트 마운트 시 저장된 상태 확인 또는 영상 생성 시작 + useEffect(() => { + const savedState = loadFromStorage(); + + // 저장된 상태가 있고, 같은 songTaskId인 경우 + if (savedState && savedState.songTaskId === songTaskId) { + if (savedState.status === 'complete' && savedState.videoUrl) { + // 이미 완료된 경우 + setVideoUrl(savedState.videoUrl); + setVideoStatus('complete'); + hasStartedGeneration.current = true; + } else if (savedState.status === 'polling') { + // 폴링 중이었던 경우 다시 폴링 + setVideoStatus('polling'); + setStatusMessage('영상을 처리하고 있습니다... (새로고침 후 복구됨)'); + hasStartedGeneration.current = true; + pollVideoStatus(savedState.videoTaskId, savedState.songTaskId); + } + } else if (songTaskId && !hasStartedGeneration.current) { + // 새로운 영상 생성 시작 + startVideoGeneration(); + } + }, [songTaskId]); const toggleSocial = (id: string) => { setSelectedSocials(prev => @@ -16,11 +184,55 @@ const CompletionContent: React.FC = ({ onBack }) => { ); }; + const togglePlayPause = () => { + if (!videoRef.current || !videoUrl) return; + if (isPlaying) { + videoRef.current.pause(); + } else { + videoRef.current.play(); + } + setIsPlaying(!isPlaying); + }; + + const handleTimeUpdate = () => { + if (videoRef.current && videoRef.current.duration > 0) { + setProgress((videoRef.current.currentTime / videoRef.current.duration) * 100); + } + }; + + const handleVideoEnded = () => { + setIsPlaying(false); + setProgress(0); + }; + + const handleDownload = () => { + if (videoUrl) { + const link = document.createElement('a'); + link.href = videoUrl; + link.download = 'castad_video.mp4'; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + }; + + const handleRetry = () => { + hasStartedGeneration.current = false; + setVideoStatus('idle'); + setVideoUrl(null); + setErrorMessage(null); + clearStorage(); + startVideoGeneration(); + }; + const socials = [ { id: 'Youtube', email: 'o2ocorp@o2o.kr', color: '#ff0000', icon: }, { id: 'Instagram', email: 'o2ocorp@o2o.kr', color: '#e4405f', icon: }, ]; + const isLoading = videoStatus === 'generating' || videoStatus === 'polling'; + return (
{/* Back Button */} @@ -35,9 +247,15 @@ const CompletionContent: React.FC = ({ onBack }) => { {/* Header */}
-

콘텐츠 제작 완료

+

+ {isLoading ? '영상 생성 중' : videoStatus === 'error' ? '영상 생성 실패' : '콘텐츠 제작 완료'} +

- 인스타그램 릴스 및 틱톡에 최적화된 고성능 영상을 제작했습니다. + {isLoading + ? statusMessage || '잠시만 기다려주세요...' + : videoStatus === 'error' + ? errorMessage || '오류가 발생했습니다.' + : '인스타그램 릴스 및 틱톡에 최적화된 고성능 영상을 제작했습니다.'}

@@ -48,29 +266,72 @@ const CompletionContent: React.FC = ({ onBack }) => {

이미지 및 영상

-
- - {/* Video Player Controls */} -
-
- -
-
+
+ ) : videoUrl ? ( + /* Video Player */ +
+ )}
- {/* Tags */} -
- {['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => ( - - {tag} - - ))} -
+ {/* Tags - only show when complete */} + {videoStatus === 'complete' && ( +
+ {['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => ( + + {tag} + + ))} +
+ )}
{/* Right: Sharing */} @@ -83,8 +344,8 @@ const CompletionContent: React.FC = ({ onBack }) => { return (
toggleSocial(social.id)} - className={`social-card ${isSelected ? 'selected' : ''}`} + onClick={() => videoStatus === 'complete' && toggleSocial(social.id)} + className={`social-card ${isSelected ? 'selected' : ''} ${videoStatus !== 'complete' ? 'disabled' : ''}`} >
= ({ onBack }) => {
-
diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 0b49053..4e2e56e 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -10,6 +10,21 @@ import { ImageItem } from '../../types/api'; const WIZARD_STEP_KEY = 'castad_wizard_step'; const ACTIVE_ITEM_KEY = 'castad_active_item'; +const SONG_TASK_ID_KEY = 'castad_song_task_id'; +const IMAGE_TASK_ID_KEY = 'castad_image_task_id'; + +// 다른 컴포넌트에서 사용하는 storage key들 (초기화용) +const SONG_GENERATION_KEY = 'castad_song_generation'; +const VIDEO_GENERATION_KEY = 'castad_video_generation'; + +// 모든 프로젝트 관련 localStorage 초기화 +const clearAllProjectStorage = () => { + localStorage.removeItem(WIZARD_STEP_KEY); + localStorage.removeItem(SONG_TASK_ID_KEY); + localStorage.removeItem(IMAGE_TASK_ID_KEY); + localStorage.removeItem(SONG_GENERATION_KEY); + localStorage.removeItem(VIDEO_GENERATION_KEY); +}; interface BusinessInfo { customer_name: string; @@ -30,9 +45,13 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt // localStorage에서 저장된 상태 복원 const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY); const savedWizardStep = localStorage.getItem(WIZARD_STEP_KEY); + const savedSongTaskId = localStorage.getItem(SONG_TASK_ID_KEY); + const savedImageTaskId = localStorage.getItem(IMAGE_TASK_ID_KEY); const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem); const [maxWizardIndex, setMaxWizardIndex] = useState(savedWizardStep ? parseInt(savedWizardStep, 10) : 0); + const [songTaskId, setSongTaskId] = useState(savedSongTaskId); + const [imageTaskId, setImageTaskId] = useState(savedImageTaskId); // URL 이미지를 ImageItem 형태로 변환하여 초기화 const [imageList, setImageList] = useState( @@ -59,6 +78,16 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt setImageList(prev => [...prev, ...newImages]); }; + // 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동 + const handleHome = () => { + clearAllProjectStorage(); + setMaxWizardIndex(0); + setSongTaskId(null); + setImageTaskId(null); + setImageList(initialImageList.map(url => ({ type: 'url', url }))); + onHome(); + }; + const scrollToWizardSection = (index: number) => { if (scrollContainerRef.current) { const sections = scrollContainerRef.current.querySelectorAll('.flow-section'); @@ -147,7 +176,11 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt
setActiveItem('대시보드')} - onNext={() => scrollToWizardSection(1)} + onNext={(taskId: string) => { + setImageTaskId(taskId); + localStorage.setItem(IMAGE_TASK_ID_KEY, taskId); + scrollToWizardSection(1); + }} imageList={imageList} onRemoveImage={handleRemoveImage} onAddImages={handleAddImages} @@ -157,14 +190,20 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt
scrollToWizardSection(0)} - onNext={() => scrollToWizardSection(2)} + onNext={(taskId: string) => { + setSongTaskId(taskId); + localStorage.setItem(SONG_TASK_ID_KEY, taskId); + scrollToWizardSection(2); + }} businessInfo={businessInfo} + imageTaskId={imageTaskId} />
{/* Step 2: Completion */}
- scrollToWizardSection(1)} + scrollToWizardSection(1)} + songTaskId={songTaskId} />
@@ -180,7 +219,7 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt return (
- +
{renderContent()}
diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx index 6d8f395..998e40f 100755 --- a/src/pages/Dashboard/SoundStudioContent.tsx +++ b/src/pages/Dashboard/SoundStudioContent.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; -import { generateLyric, generateSong, waitForSongComplete } from '../../utils/api'; +import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api'; import { LANGUAGE_MAP } from '../../types/api'; interface BusinessInfo { @@ -11,14 +11,16 @@ interface BusinessInfo { interface SoundStudioContentProps { onBack: () => void; - onNext: () => void; + onNext: (songTaskId: string) => void; businessInfo?: BusinessInfo; + imageTaskId: string | null; } type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error'; interface SavedGenerationState { taskId: string; + sunoTaskId: string; lyrics: string; status: GenerationStatus; timestamp: number; @@ -28,7 +30,7 @@ const STORAGE_KEY = 'castad_song_generation'; const STORAGE_EXPIRY = 30 * 60 * 1000; const MAX_RETRY_COUNT = 3; -const SoundStudioContent: React.FC = ({ onBack, onNext, businessInfo }) => { +const SoundStudioContent: React.FC = ({ onBack, onNext, businessInfo, imageTaskId }) => { const [selectedType, setSelectedType] = useState('보컬'); const [selectedLang, setSelectedLang] = useState('한국어'); const [selectedGenre, setSelectedGenre] = useState('AI 추천'); @@ -44,13 +46,15 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, const [errorMessage, setErrorMessage] = useState(null); const [statusMessage, setStatusMessage] = useState(''); const [retryCount, setRetryCount] = useState(0); + const [songTaskId, setSongTaskId] = useState(null); const progressBarRef = useRef(null); const audioRef = useRef(null); - const saveToStorage = (taskId: string, currentLyrics: string, currentStatus: GenerationStatus) => { + const saveToStorage = (taskId: string, sunoTaskId: string, currentLyrics: string, currentStatus: GenerationStatus) => { const data: SavedGenerationState = { taskId, + sunoTaskId, lyrics: currentLyrics, status: currentStatus, timestamp: Date.now(), @@ -90,15 +94,16 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, } setStatus('polling'); setStatusMessage('음악을 처리하고 있습니다... (새로고침 후 복구됨)'); - resumePolling(savedState.taskId, savedState.lyrics, 0); + resumePolling(savedState.taskId, savedState.sunoTaskId, savedState.lyrics, 0); } }, []); - const resumePolling = async (taskId: string, currentLyrics: string, currentRetryCount: number = 0) => { + const resumePolling = async (taskId: string, sunoTaskId: string, currentLyrics: string, currentRetryCount: number = 0) => { try { const downloadResponse = await waitForSongComplete( taskId, - (pollStatus) => { + sunoTaskId, + (pollStatus: string) => { if (pollStatus === 'pending') { setStatusMessage('대기 중...'); } else if (pollStatus === 'processing') { @@ -111,7 +116,8 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, throw new Error(downloadResponse.error_message || '음악 다운로드에 실패했습니다.'); } - setAudioUrl(downloadResponse.file_url); + setAudioUrl(downloadResponse.song_result_url); + setSongTaskId(downloadResponse.task_id); setStatus('complete'); setStatusMessage(''); setRetryCount(0); @@ -142,7 +148,7 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, }; const regenerateSongOnly = async (currentLyrics: string, currentRetryCount: number) => { - if (!businessInfo) return; + if (!businessInfo || !imageTaskId) return; try { const language = LANGUAGE_MAP[selectedLang] || 'Korean'; @@ -153,7 +159,7 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, '어쿠스틱': 'acoustic', }; - const songResponse = await generateSong({ + const songResponse = await generateSong(imageTaskId, { genre: genreMap[selectedGenre] || 'pop', language, lyrics: currentLyrics, @@ -163,8 +169,8 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.'); } - saveToStorage(songResponse.task_id, currentLyrics, 'polling'); - await resumePolling(songResponse.task_id, currentLyrics, currentRetryCount); + saveToStorage(songResponse.task_id, songResponse.suno_task_id, currentLyrics, 'polling'); + await resumePolling(songResponse.task_id, songResponse.suno_task_id, currentLyrics, currentRetryCount); } catch (error) { console.error('Song regeneration failed:', error); @@ -264,40 +270,52 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, return; } + if (!imageTaskId) { + setErrorMessage('이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.'); + return; + } + setStatus('generating_lyric'); setErrorMessage(null); setStatusMessage('가사를 생성하고 있습니다...'); try { const language = LANGUAGE_MAP[selectedLang] || 'Korean'; - let lyricResponse = await generateLyric({ + + // 1. 가사 생성 요청 + const lyricResponse = await generateLyric({ customer_name: businessInfo.customer_name, detail_region_info: businessInfo.detail_region_info, language, region: businessInfo.region, + task_id: imageTaskId, }); - let retryCount = 0; - while (lyricResponse.lyric.includes("I'm sorry") && retryCount < 3) { - retryCount++; - setStatusMessage(`가사 재생성 중... (${retryCount}/3)`); - lyricResponse = await generateLyric({ - customer_name: businessInfo.customer_name, - detail_region_info: businessInfo.detail_region_info, - language, - region: businessInfo.region, - }); + if (!lyricResponse.success || !lyricResponse.task_id) { + throw new Error(lyricResponse.error_message || '가사 생성 요청에 실패했습니다.'); } - if (!lyricResponse.success) { - 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('가사를 받지 못했습니다.'); } - if (lyricResponse.lyric.includes("I'm sorry")) { + // "I'm sorry" 체크 + if (lyricDetailResponse.lyric_result.includes("I'm sorry")) { throw new Error('가사 생성에 실패했습니다. 다시 시도해주세요.'); } - setLyrics(lyricResponse.lyric); + setLyrics(lyricDetailResponse.lyric_result); setShowLyrics(true); setStatus('generating_song'); @@ -310,10 +328,10 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, '어쿠스틱': 'acoustic', }; - const songResponse = await generateSong({ + const songResponse = await generateSong(imageTaskId, { genre: genreMap[selectedGenre] || 'pop', language, - lyrics: lyricResponse.lyric, + lyrics: lyricDetailResponse.lyric_result, }); if (!songResponse.success) { @@ -322,9 +340,9 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, setStatus('polling'); setStatusMessage('음악을 처리하고 있습니다...'); - saveToStorage(songResponse.task_id, lyricResponse.lyric, 'polling'); + saveToStorage(songResponse.task_id, songResponse.suno_task_id, lyricDetailResponse.lyric_result, 'polling'); - await resumePolling(songResponse.task_id, lyricResponse.lyric, 0); + await resumePolling(songResponse.task_id, songResponse.suno_task_id, lyricDetailResponse.lyric_result, 0); } catch (error) { console.error('Music generation failed:', error); @@ -554,9 +572,9 @@ const SoundStudioContent: React.FC = ({ onBack, onNext, {/* Bottom Button */}
diff --git a/src/types/api.ts b/src/types/api.ts index da025e1..0f1224c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -34,17 +34,39 @@ export interface LyricGenerateRequest { detail_region_info: string; language: string; region: string; + task_id: string; } // 가사 생성 응답 export interface LyricGenerateResponse { success: boolean; - lyric: string; - language: string; - prompt_used: string; + task_id: string; + lyric?: string; + language?: string; + prompt_used?: string; error_message: string | null; } +// 가사 상태 조회 응답 +export interface LyricStatusResponse { + message: string; + status: 'processing' | 'completed' | 'failed'; + task_id: string; + lyric?: string; + error_message?: string | null; +} + +// 가사 상세 조회 응답 +export interface LyricDetailResponse { + id: number; + task_id: string; + project_id: number; + status: string; + lyric_prompt: string; + lyric_result: string; + created_at: string; +} + // 노래 생성 요청 export interface SongGenerateRequest { genre: string; @@ -56,6 +78,7 @@ export interface SongGenerateRequest { export interface SongGenerateResponse { success: boolean; task_id: string; + suno_task_id: string; message: string; error_message: string | null; } @@ -79,12 +102,75 @@ export interface SongStatusResponse { // 노래 다운로드 응답 export interface SongDownloadResponse { success: boolean; + status: string; message: string; - file_path: string; - file_url: string; + store_name: string; + region: string; + detail_region_info: string; + task_id: string; + language: string; + song_result_url: string; + created_at: string; error_message: string | null; } +// 영상 생성 응답 +export interface VideoGenerateResponse { + success: boolean; + task_id: string; + creatomate_render_id: string + message: string; + error_message: string | null; +} + +// 영상 상태 확인 응답 +export interface VideoStatusResponse { + success: boolean; + status: string; + message: string; + render_data: { + id: string; + status: string; + url: string | null; + snapshot_url: string | null; + } | null; + raw_response?: Record; + error_message: string | null; +} + +// 영상 다운로드(결과 조회) 응답 +export interface VideoDownloadResponse { + success: boolean; + status: string; + message: string; + store_name: string; + region: string; + task_id: string; + result_movie_url: string | null; + created_at: string; + error_message: string | null; +} + +// 이미지 업로드 요청용 URL 이미지 정보 +export interface ImageUrlItem { + url: string; + name?: string; +} + +// 이미지 업로드 응답 +export interface ImageUploadResponse { + task_id: string; + file_count: number; + image_urls: string[]; + images: Array<{ + id: number; + img_name: string; + img_order: number; + img_url: string; + source: string; + }>; +} + // 언어 매핑 export const LANGUAGE_MAP: Record = { '한국어': 'Korean', diff --git a/src/utils/api.ts b/src/utils/api.ts index 100ad72..a0f1250 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -2,10 +2,17 @@ import { CrawlingResponse, LyricGenerateRequest, LyricGenerateResponse, + LyricStatusResponse, + LyricDetailResponse, SongGenerateRequest, SongGenerateResponse, SongStatusResponse, SongDownloadResponse, + VideoGenerateResponse, + VideoStatusResponse, + VideoDownloadResponse, + ImageUrlItem, + ImageUploadResponse, } from '../types/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; @@ -43,9 +50,75 @@ export async function generateLyric(request: LyricGenerateRequest): Promise { - const response = await fetch(`${API_URL}/song/generate`, { +// 가사 상태 조회 API +export async function getLyricStatus(taskId: string): Promise { + const response = await fetch(`${API_URL}/lyric/status/${taskId}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 가사 상세 조회 API +export async function getLyricDetail(taskId: string): Promise { + const response = await fetch(`${API_URL}/lyric/${taskId}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 가사 생성 완료까지 폴링 (2분 타임아웃, 1초 간격) +const LYRIC_POLL_TIMEOUT = 2 * 60 * 1000; // 2분 +const LYRIC_POLL_INTERVAL = 1000; // 1초 + +export async function waitForLyricComplete( + taskId: string, + onStatusChange?: (status: string) => void +): Promise { + const startTime = Date.now(); + + // 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작) + const poll = async (): Promise => { + // 2분 타임아웃 체크 + if (Date.now() - startTime > LYRIC_POLL_TIMEOUT) { + throw new Error('TIMEOUT'); + } + + try { + const statusResponse = await getLyricStatus(taskId); + onStatusChange?.(statusResponse.status); + + if (statusResponse.status === 'completed') { + // 완료되면 상세 조회로 가사 가져오기 + const detailResponse = await getLyricDetail(taskId); + return detailResponse; + } else if (statusResponse.status === 'failed') { + throw new Error(statusResponse.error_message || '가사 생성에 실패했습니다.'); + } + + // processing은 대기 후 재시도 + await new Promise(resolve => setTimeout(resolve, LYRIC_POLL_INTERVAL)); + return poll(); + } catch (error) { + throw error; + } + }; + + return poll(); +} + +// 노래 생성 API (task_id는 URL 경로에 포함) +export async function generateSong(taskId: string, request: SongGenerateRequest): Promise { + const response = await fetch(`${API_URL}/song/generate/${taskId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -60,9 +133,9 @@ export async function generateSong(request: SongGenerateRequest): Promise { - const response = await fetch(`${API_URL}/song/status/${taskId}`, { +// 노래 상태 확인 API (suno_task_id 사용) +export async function getSongStatus(sunoTaskId: string): Promise { + const response = await fetch(`${API_URL}/song/status/${sunoTaskId}`, { method: 'GET', }); @@ -92,51 +165,165 @@ const POLL_INTERVAL = 5000; // 5초 export async function waitForSongComplete( taskId: string, + sunoTaskId: string, onStatusChange?: (status: string) => void ): Promise { - return new Promise((resolve, reject) => { - const startTime = Date.now(); + const startTime = Date.now(); - const pollInterval = setInterval(async () => { - try { - // 2분 타임아웃 체크 - if (Date.now() - startTime > POLL_TIMEOUT) { - clearInterval(pollInterval); - reject(new Error('TIMEOUT')); - return; - } + // 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작) + const poll = async (): Promise => { + // 2분 타임아웃 체크 + if (Date.now() - startTime > POLL_TIMEOUT) { + throw new Error('TIMEOUT'); + } - const statusResponse = await getSongStatus(taskId); - onStatusChange?.(statusResponse.status); + try { + // 상태 확인은 suno_task_id 사용 + const statusResponse = await getSongStatus(sunoTaskId); + onStatusChange?.(statusResponse.status); - // status가 "SUCCESS" (대문자)인 경우 완료 - if (statusResponse.status === 'SUCCESS' && statusResponse.success) { - clearInterval(pollInterval); - - // clips에서 첫 번째 오디오 URL 가져오기 - if (statusResponse.clips && statusResponse.clips.length > 0) { - const clip = statusResponse.clips[0]; - resolve({ - success: true, - message: statusResponse.message || '노래 생성이 완료되었습니다.', - file_path: '', - file_url: clip.audio_url || clip.stream_audio_url, - error_message: null, - }); - } else { - // clips가 없으면 download API 호출 - const downloadResponse = await downloadSong(taskId); - resolve(downloadResponse); - } - } else if (statusResponse.status === 'FAILED' || statusResponse.status === 'failed') { - clearInterval(pollInterval); - reject(new Error('Song generation failed')); - } - // PENDING, PROCESSING 등은 계속 폴링 - } catch (error) { - clearInterval(pollInterval); - reject(error); + // status가 "SUCCESS" (대문자)인 경우 완료 + if (statusResponse.status === 'SUCCESS' && statusResponse.success) { + // 다운로드는 task_id 사용 + const downloadResponse = await downloadSong(taskId); + return downloadResponse; + } else if (statusResponse.status === 'FAILED' || statusResponse.status === 'failed') { + throw new Error('Song generation failed'); } - }, POLL_INTERVAL); - }); + + // PENDING, PROCESSING 등은 대기 후 재시도 + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); + return poll(); + } catch (error) { + throw error; + } + }; + + return poll(); +} + +// 영상 생성 API +export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise { + const response = await fetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 영상 상태 확인 API +export async function getVideoStatus(taskId: string): Promise { + const response = await fetch(`${API_URL}/video/status/${taskId}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 영상 다운로드(결과 조회) API +export async function downloadVideo(taskId: string): Promise { + const response = await fetch(`${API_URL}/video/download/${taskId}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 이미지 업로드 API (multipart/form-data) +// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음) +const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000; + +export async function uploadImages( + imageUrls: ImageUrlItem[], + files: File[] +): Promise { + const formData = new FormData(); + + // URL 이미지들을 images_json으로 전달 + if (imageUrls.length > 0) { + formData.append('images_json', JSON.stringify(imageUrls)); + } + + // 파일들을 files로 전달 + files.forEach((file) => { + formData.append('files', file); + }); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT); + + try { + const response = await fetch(`${API_URL}/image/upload/blob`, { + method: 'POST', + body: formData, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('이미지 업로드 시간이 초과되었습니다. 다시 시도해주세요.'); + } + throw error; + } +} + +// 영상 생성 완료까지 폴링 (3분 타임아웃, 3초 간격) +const VIDEO_POLL_TIMEOUT = 3 * 60 * 1000; // 3분 +const VIDEO_POLL_INTERVAL = 3000; // 3초 + +export async function waitForVideoComplete( + taskId: string, + onStatusChange?: (status: string) => void +): Promise { + const startTime = Date.now(); + + // 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작) + const poll = async (): Promise => { + // 3분 타임아웃 체크 + if (Date.now() - startTime > VIDEO_POLL_TIMEOUT) { + throw new Error('TIMEOUT'); + } + + try { + const statusResponse = await getVideoStatus(taskId); + // render_data.status를 전달 (planned, waiting, transcribing, rendering, succeeded, failed) + const renderStatus = statusResponse.render_data?.status; + onStatusChange?.(renderStatus || statusResponse.status); + + // render_data.status가 "succeeded"일 때만 완료 + if (renderStatus === 'succeeded') { + return statusResponse; + } else if (renderStatus === 'failed' || statusResponse.status === 'FAILED' || statusResponse.status === 'failed') { + throw new Error(statusResponse.error_message || 'Video generation failed'); + } + + // pending, rendering 등은 대기 후 재시도 + await new Promise(resolve => setTimeout(resolve, VIDEO_POLL_INTERVAL)); + return poll(); + } catch (error) { + throw error; + } + }; + + return poll(); }