front 작업 중 .
parent
ecbd81d091
commit
24d61f925a
|
|
@ -4,7 +4,8 @@
|
|||
"Bash(mkdir:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(rmdir:*)",
|
||||
"Bash(rm:*)"
|
||||
"Bash(rm:*)",
|
||||
"Bash(npm run build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
41
index.css
41
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
|
||||
===================================================== */
|
||||
|
|
|
|||
|
|
@ -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<AssetManagementContentProps> = ({
|
|||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageListRef = useRef<HTMLDivElement>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(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<AssetManagementContentProps> = ({
|
|||
|
||||
{/* Bottom Button */}
|
||||
<div className="bottom-button-container">
|
||||
{uploadError && (
|
||||
<p className="text-red-500 text-sm mb-2">{uploadError}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={imageList.length === 0}
|
||||
className={`btn-primary ${imageList.length === 0 ? 'disabled' : ''}`}
|
||||
onClick={handleNextWithUpload}
|
||||
disabled={imageList.length === 0 || isUploading}
|
||||
className={`btn-primary ${imageList.length === 0 || isUploading ? 'disabled' : ''}`}
|
||||
>
|
||||
다음 단계
|
||||
{isUploading ? '업로드 중...' : '다음 단계'}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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<CompletionContentProps> = ({ 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<CompletionContentProps> = ({ onBack, songTaskId }) => {
|
||||
const [selectedSocials, setSelectedSocials] = useState<string[]>([]);
|
||||
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const videoRef = useRef<HTMLVideoElement>(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<CompletionContentProps> = ({ 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: <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 4-8 4z"/></svg> },
|
||||
{ id: 'Instagram', email: 'o2ocorp@o2o.kr', color: '#e4405f', icon: <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259 0 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.791-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.209-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg> },
|
||||
];
|
||||
|
||||
const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
|
||||
|
||||
return (
|
||||
<main className="completion-container">
|
||||
{/* Back Button */}
|
||||
|
|
@ -35,9 +247,15 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
|
|||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">콘텐츠 제작 완료</h1>
|
||||
<h1 className="page-title">
|
||||
{isLoading ? '영상 생성 중' : videoStatus === 'error' ? '영상 생성 실패' : '콘텐츠 제작 완료'}
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
인스타그램 릴스 및 틱톡에 최적화된 고성능 영상을 제작했습니다.
|
||||
{isLoading
|
||||
? statusMessage || '잠시만 기다려주세요...'
|
||||
: videoStatus === 'error'
|
||||
? errorMessage || '오류가 발생했습니다.'
|
||||
: '인스타그램 릴스 및 틱톡에 최적화된 고성능 영상을 제작했습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -48,29 +266,72 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
|
|||
<h3 className="section-title mb-4 shrink-0">이미지 및 영상</h3>
|
||||
|
||||
<div className="video-container">
|
||||
<div className="video-pattern"></div>
|
||||
|
||||
{/* Video Player Controls */}
|
||||
<div className="video-controls">
|
||||
<div className="video-controls-inner">
|
||||
<button className="video-play-btn">
|
||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
{isLoading ? (
|
||||
/* Loading State */
|
||||
<div className="video-loading">
|
||||
<div className="loading-spinner">
|
||||
<div className="loading-ring"></div>
|
||||
<div className="loading-dot">
|
||||
<div className="loading-dot-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-400 mt-4">{statusMessage}</p>
|
||||
</div>
|
||||
) : videoStatus === 'error' ? (
|
||||
/* Error State */
|
||||
<div className="video-error">
|
||||
<svg className="w-16 h-16 text-red-500 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<p className="text-gray-400 mb-4">{errorMessage}</p>
|
||||
<button onClick={handleRetry} className="btn-secondary">
|
||||
다시 시도
|
||||
</button>
|
||||
<div className="video-progress">
|
||||
<div className="video-progress-fill" style={{ width: '40%' }}></div>
|
||||
</div>
|
||||
) : videoUrl ? (
|
||||
/* Video Player */
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="video-player"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={handleVideoEnded}
|
||||
onClick={togglePlayPause}
|
||||
/>
|
||||
) : (
|
||||
<div className="video-pattern"></div>
|
||||
)}
|
||||
|
||||
{/* Video Player Controls - only show when video is ready */}
|
||||
{videoStatus === 'complete' && videoUrl && (
|
||||
<div className="video-controls">
|
||||
<div className="video-controls-inner">
|
||||
<button className="video-play-btn" onClick={togglePlayPause}>
|
||||
{isPlaying ? (
|
||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
) : (
|
||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="video-progress">
|
||||
<div className="video-progress-fill" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="tags-container">
|
||||
{['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => (
|
||||
<span key={tag} className="tag-dot">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Tags - only show when complete */}
|
||||
{videoStatus === 'complete' && (
|
||||
<div className="tags-container">
|
||||
{['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => (
|
||||
<span key={tag} className="tag-dot">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Sharing */}
|
||||
|
|
@ -83,8 +344,8 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
|
|||
return (
|
||||
<div
|
||||
key={social.id}
|
||||
onClick={() => toggleSocial(social.id)}
|
||||
className={`social-card ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => videoStatus === 'complete' && toggleSocial(social.id)}
|
||||
className={`social-card ${isSelected ? 'selected' : ''} ${videoStatus !== 'complete' ? 'disabled' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="social-icon"
|
||||
|
|
@ -107,13 +368,17 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
|
|||
</div>
|
||||
|
||||
<button
|
||||
disabled={selectedSocials.length === 0}
|
||||
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'}
|
||||
className="btn-deploy"
|
||||
>
|
||||
소셜 채널에 배포
|
||||
</button>
|
||||
|
||||
<button className="btn-download">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={videoStatus !== 'complete' || !videoUrl}
|
||||
className="btn-download"
|
||||
>
|
||||
MP4 파일 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<GenerationFlowProps> = ({ 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<string | null>(savedSongTaskId);
|
||||
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
|
||||
|
||||
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
||||
const [imageList, setImageList] = useState<ImageItem[]>(
|
||||
|
|
@ -59,6 +78,16 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ 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<GenerationFlowProps> = ({ onHome, initialActiveIt
|
|||
<div className="flow-section snap-start h-full w-full flex flex-col overflow-hidden">
|
||||
<AssetManagementContent
|
||||
onBack={() => 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<GenerationFlowProps> = ({ onHome, initialActiveIt
|
|||
<div className="flow-section snap-start h-full w-full flex flex-col">
|
||||
<SoundStudioContent
|
||||
onBack={() => scrollToWizardSection(0)}
|
||||
onNext={() => scrollToWizardSection(2)}
|
||||
onNext={(taskId: string) => {
|
||||
setSongTaskId(taskId);
|
||||
localStorage.setItem(SONG_TASK_ID_KEY, taskId);
|
||||
scrollToWizardSection(2);
|
||||
}}
|
||||
businessInfo={businessInfo}
|
||||
imageTaskId={imageTaskId}
|
||||
/>
|
||||
</div>
|
||||
{/* Step 2: Completion */}
|
||||
<div className="flow-section snap-start h-full w-full flex flex-col overflow-hidden">
|
||||
<CompletionContent
|
||||
onBack={() => scrollToWizardSection(1)}
|
||||
songTaskId={songTaskId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -180,7 +219,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
|||
|
||||
return (
|
||||
<div className="flex w-full h-[100dvh] bg-[#0d1416] text-white overflow-hidden">
|
||||
<Sidebar activeItem={activeItem} onNavigate={setActiveItem} onHome={onHome} />
|
||||
<Sidebar activeItem={activeItem} onNavigate={setActiveItem} onHome={handleHome} />
|
||||
<div className="flex-1 h-full relative overflow-hidden pl-0 md:pl-0">
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<SoundStudioContentProps> = ({ onBack, onNext, businessInfo }) => {
|
||||
const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ 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<SoundStudioContentProps> = ({ onBack, onNext,
|
|||
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);
|
||||
|
||||
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<SoundStudioContentProps> = ({ 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<SoundStudioContentProps> = ({ 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<SoundStudioContentProps> = ({ 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<SoundStudioContentProps> = ({ 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<SoundStudioContentProps> = ({ 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<SoundStudioContentProps> = ({ 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<SoundStudioContentProps> = ({ 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<SoundStudioContentProps> = ({ 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<SoundStudioContentProps> = ({ onBack, onNext,
|
|||
{/* Bottom Button */}
|
||||
<div className="bottom-button-container">
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={status !== 'complete'}
|
||||
className={`btn-primary ${status !== 'complete' ? 'disabled' : ''}`}
|
||||
onClick={() => songTaskId && onNext(songTaskId)}
|
||||
disabled={status !== 'complete' || !songTaskId}
|
||||
className={`btn-primary ${status !== 'complete' || !songTaskId ? 'disabled' : ''}`}
|
||||
>
|
||||
영상 생성하기
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string, string> = {
|
||||
'한국어': 'Korean',
|
||||
|
|
|
|||
281
src/utils/api.ts
281
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<Lyri
|
|||
return response.json();
|
||||
}
|
||||
|
||||
// 노래 생성 API
|
||||
export async function generateSong(request: SongGenerateRequest): Promise<SongGenerateResponse> {
|
||||
const response = await fetch(`${API_URL}/song/generate`, {
|
||||
// 가사 상태 조회 API
|
||||
export async function getLyricStatus(taskId: string): Promise<LyricStatusResponse> {
|
||||
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<LyricDetailResponse> {
|
||||
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<LyricDetailResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작)
|
||||
const poll = async (): Promise<LyricDetailResponse> => {
|
||||
// 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<SongGenerateResponse> {
|
||||
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<SongGe
|
|||
return response.json();
|
||||
}
|
||||
|
||||
// 노래 상태 확인 API
|
||||
export async function getSongStatus(taskId: string): Promise<SongStatusResponse> {
|
||||
const response = await fetch(`${API_URL}/song/status/${taskId}`, {
|
||||
// 노래 상태 확인 API (suno_task_id 사용)
|
||||
export async function getSongStatus(sunoTaskId: string): Promise<SongStatusResponse> {
|
||||
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<SongDownloadResponse> {
|
||||
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<SongDownloadResponse> => {
|
||||
// 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<VideoGenerateResponse> {
|
||||
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<VideoStatusResponse> {
|
||||
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<VideoDownloadResponse> {
|
||||
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<ImageUploadResponse> {
|
||||
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<VideoStatusResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작)
|
||||
const poll = async (): Promise<VideoStatusResponse> => {
|
||||
// 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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue