front 작업 중 .
parent
ecbd81d091
commit
24d61f925a
|
|
@ -4,7 +4,8 @@
|
||||||
"Bash(mkdir:*)",
|
"Bash(mkdir:*)",
|
||||||
"Bash(mv:*)",
|
"Bash(mv:*)",
|
||||||
"Bash(rmdir:*)",
|
"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);
|
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
|
Tags Container
|
||||||
===================================================== */
|
===================================================== */
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { ImageItem } from '../../types/api';
|
import { ImageItem, ImageUrlItem } from '../../types/api';
|
||||||
|
import { uploadImages } from '../../utils/api';
|
||||||
|
|
||||||
interface AssetManagementContentProps {
|
interface AssetManagementContentProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onNext: () => void;
|
onNext: (imageTaskId: string) => void;
|
||||||
imageList: ImageItem[];
|
imageList: ImageItem[];
|
||||||
onRemoveImage: (index: number) => void;
|
onRemoveImage: (index: number) => void;
|
||||||
onAddImages: (files: File[]) => void;
|
onAddImages: (files: File[]) => void;
|
||||||
|
|
@ -19,11 +20,42 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imageListRef = useRef<HTMLDivElement>(null);
|
const imageListRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const getImageSrc = (item: ImageItem): string => {
|
const getImageSrc = (item: ImageItem): string => {
|
||||||
return item.type === 'url' ? item.url : item.preview;
|
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 handleImageListWheel = (e: React.WheelEvent) => {
|
||||||
const el = imageListRef.current;
|
const el = imageListRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -172,12 +204,15 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
||||||
|
|
||||||
{/* Bottom Button */}
|
{/* Bottom Button */}
|
||||||
<div className="bottom-button-container">
|
<div className="bottom-button-container">
|
||||||
|
{uploadError && (
|
||||||
|
<p className="text-red-500 text-sm mb-2">{uploadError}</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onNext}
|
onClick={handleNextWithUpload}
|
||||||
disabled={imageList.length === 0}
|
disabled={imageList.length === 0 || isUploading}
|
||||||
className={`btn-primary ${imageList.length === 0 ? 'disabled' : ''}`}
|
className={`btn-primary ${imageList.length === 0 || isUploading ? 'disabled' : ''}`}
|
||||||
>
|
>
|
||||||
다음 단계
|
{isUploading ? '업로드 중...' : '다음 단계'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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 {
|
interface CompletionContentProps {
|
||||||
onBack: () => void;
|
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 [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) => {
|
const toggleSocial = (id: string) => {
|
||||||
setSelectedSocials(prev =>
|
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 = [
|
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: '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> },
|
{ 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 (
|
return (
|
||||||
<main className="completion-container">
|
<main className="completion-container">
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
|
|
@ -35,9 +247,15 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1 className="page-title">콘텐츠 제작 완료</h1>
|
<h1 className="page-title">
|
||||||
|
{isLoading ? '영상 생성 중' : videoStatus === 'error' ? '영상 생성 실패' : '콘텐츠 제작 완료'}
|
||||||
|
</h1>
|
||||||
<p className="page-subtitle">
|
<p className="page-subtitle">
|
||||||
인스타그램 릴스 및 틱톡에 최적화된 고성능 영상을 제작했습니다.
|
{isLoading
|
||||||
|
? statusMessage || '잠시만 기다려주세요...'
|
||||||
|
: videoStatus === 'error'
|
||||||
|
? errorMessage || '오류가 발생했습니다.'
|
||||||
|
: '인스타그램 릴스 및 틱톡에 최적화된 고성능 영상을 제작했습니다.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -48,29 +266,72 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
|
||||||
<h3 className="section-title mb-4 shrink-0">이미지 및 영상</h3>
|
<h3 className="section-title mb-4 shrink-0">이미지 및 영상</h3>
|
||||||
|
|
||||||
<div className="video-container">
|
<div className="video-container">
|
||||||
<div className="video-pattern"></div>
|
{isLoading ? (
|
||||||
|
/* Loading State */
|
||||||
{/* Video Player Controls */}
|
<div className="video-loading">
|
||||||
<div className="video-controls">
|
<div className="loading-spinner">
|
||||||
<div className="video-controls-inner">
|
<div className="loading-ring"></div>
|
||||||
<button className="video-play-btn">
|
<div className="loading-dot">
|
||||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
<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>
|
</button>
|
||||||
<div className="video-progress">
|
</div>
|
||||||
<div className="video-progress-fill" style={{ width: '40%' }}></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>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags - only show when complete */}
|
||||||
<div className="tags-container">
|
{videoStatus === 'complete' && (
|
||||||
{['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => (
|
<div className="tags-container">
|
||||||
<span key={tag} className="tag-dot">
|
{['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => (
|
||||||
{tag}
|
<span key={tag} className="tag-dot">
|
||||||
</span>
|
{tag}
|
||||||
))}
|
</span>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Sharing */}
|
{/* Right: Sharing */}
|
||||||
|
|
@ -83,8 +344,8 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={social.id}
|
key={social.id}
|
||||||
onClick={() => toggleSocial(social.id)}
|
onClick={() => videoStatus === 'complete' && toggleSocial(social.id)}
|
||||||
className={`social-card ${isSelected ? 'selected' : ''}`}
|
className={`social-card ${isSelected ? 'selected' : ''} ${videoStatus !== 'complete' ? 'disabled' : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="social-icon"
|
className="social-icon"
|
||||||
|
|
@ -107,13 +368,17 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
disabled={selectedSocials.length === 0}
|
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'}
|
||||||
className="btn-deploy"
|
className="btn-deploy"
|
||||||
>
|
>
|
||||||
소셜 채널에 배포
|
소셜 채널에 배포
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="btn-download">
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={videoStatus !== 'complete' || !videoUrl}
|
||||||
|
className="btn-download"
|
||||||
|
>
|
||||||
MP4 파일 다운로드
|
MP4 파일 다운로드
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,21 @@ import { ImageItem } from '../../types/api';
|
||||||
|
|
||||||
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
||||||
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
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 {
|
interface BusinessInfo {
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
|
|
@ -30,9 +45,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
||||||
// localStorage에서 저장된 상태 복원
|
// localStorage에서 저장된 상태 복원
|
||||||
const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY);
|
const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY);
|
||||||
const savedWizardStep = localStorage.getItem(WIZARD_STEP_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 [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem);
|
||||||
const [maxWizardIndex, setMaxWizardIndex] = useState(savedWizardStep ? parseInt(savedWizardStep, 10) : 0);
|
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 형태로 변환하여 초기화
|
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
||||||
const [imageList, setImageList] = useState<ImageItem[]>(
|
const [imageList, setImageList] = useState<ImageItem[]>(
|
||||||
|
|
@ -59,6 +78,16 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
||||||
setImageList(prev => [...prev, ...newImages]);
|
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) => {
|
const scrollToWizardSection = (index: number) => {
|
||||||
if (scrollContainerRef.current) {
|
if (scrollContainerRef.current) {
|
||||||
const sections = scrollContainerRef.current.querySelectorAll('.flow-section');
|
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">
|
<div className="flow-section snap-start h-full w-full flex flex-col overflow-hidden">
|
||||||
<AssetManagementContent
|
<AssetManagementContent
|
||||||
onBack={() => setActiveItem('대시보드')}
|
onBack={() => setActiveItem('대시보드')}
|
||||||
onNext={() => scrollToWizardSection(1)}
|
onNext={(taskId: string) => {
|
||||||
|
setImageTaskId(taskId);
|
||||||
|
localStorage.setItem(IMAGE_TASK_ID_KEY, taskId);
|
||||||
|
scrollToWizardSection(1);
|
||||||
|
}}
|
||||||
imageList={imageList}
|
imageList={imageList}
|
||||||
onRemoveImage={handleRemoveImage}
|
onRemoveImage={handleRemoveImage}
|
||||||
onAddImages={handleAddImages}
|
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">
|
<div className="flow-section snap-start h-full w-full flex flex-col">
|
||||||
<SoundStudioContent
|
<SoundStudioContent
|
||||||
onBack={() => scrollToWizardSection(0)}
|
onBack={() => scrollToWizardSection(0)}
|
||||||
onNext={() => scrollToWizardSection(2)}
|
onNext={(taskId: string) => {
|
||||||
|
setSongTaskId(taskId);
|
||||||
|
localStorage.setItem(SONG_TASK_ID_KEY, taskId);
|
||||||
|
scrollToWizardSection(2);
|
||||||
|
}}
|
||||||
businessInfo={businessInfo}
|
businessInfo={businessInfo}
|
||||||
|
imageTaskId={imageTaskId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Step 2: Completion */}
|
{/* Step 2: Completion */}
|
||||||
<div className="flow-section snap-start h-full w-full flex flex-col overflow-hidden">
|
<div className="flow-section snap-start h-full w-full flex flex-col overflow-hidden">
|
||||||
<CompletionContent
|
<CompletionContent
|
||||||
onBack={() => scrollToWizardSection(1)}
|
onBack={() => scrollToWizardSection(1)}
|
||||||
|
songTaskId={songTaskId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -180,7 +219,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full h-[100dvh] bg-[#0d1416] text-white overflow-hidden">
|
<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">
|
<div className="flex-1 h-full relative overflow-hidden pl-0 md:pl-0">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
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';
|
import { LANGUAGE_MAP } from '../../types/api';
|
||||||
|
|
||||||
interface BusinessInfo {
|
interface BusinessInfo {
|
||||||
|
|
@ -11,14 +11,16 @@ interface BusinessInfo {
|
||||||
|
|
||||||
interface SoundStudioContentProps {
|
interface SoundStudioContentProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onNext: () => void;
|
onNext: (songTaskId: string) => void;
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
|
imageTaskId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
|
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
|
||||||
|
|
||||||
interface SavedGenerationState {
|
interface SavedGenerationState {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
sunoTaskId: string;
|
||||||
lyrics: string;
|
lyrics: string;
|
||||||
status: GenerationStatus;
|
status: GenerationStatus;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|
@ -28,7 +30,7 @@ const STORAGE_KEY = 'castad_song_generation';
|
||||||
const STORAGE_EXPIRY = 30 * 60 * 1000;
|
const STORAGE_EXPIRY = 30 * 60 * 1000;
|
||||||
const MAX_RETRY_COUNT = 3;
|
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 [selectedType, setSelectedType] = useState('보컬');
|
||||||
const [selectedLang, setSelectedLang] = useState('한국어');
|
const [selectedLang, setSelectedLang] = useState('한국어');
|
||||||
const [selectedGenre, setSelectedGenre] = useState('AI 추천');
|
const [selectedGenre, setSelectedGenre] = useState('AI 추천');
|
||||||
|
|
@ -44,13 +46,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
const [songTaskId, setSongTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(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 = {
|
const data: SavedGenerationState = {
|
||||||
taskId,
|
taskId,
|
||||||
|
sunoTaskId,
|
||||||
lyrics: currentLyrics,
|
lyrics: currentLyrics,
|
||||||
status: currentStatus,
|
status: currentStatus,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -90,15 +94,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
}
|
}
|
||||||
setStatus('polling');
|
setStatus('polling');
|
||||||
setStatusMessage('음악을 처리하고 있습니다... (새로고침 후 복구됨)');
|
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 {
|
try {
|
||||||
const downloadResponse = await waitForSongComplete(
|
const downloadResponse = await waitForSongComplete(
|
||||||
taskId,
|
taskId,
|
||||||
(pollStatus) => {
|
sunoTaskId,
|
||||||
|
(pollStatus: string) => {
|
||||||
if (pollStatus === 'pending') {
|
if (pollStatus === 'pending') {
|
||||||
setStatusMessage('대기 중...');
|
setStatusMessage('대기 중...');
|
||||||
} else if (pollStatus === 'processing') {
|
} else if (pollStatus === 'processing') {
|
||||||
|
|
@ -111,7 +116,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
throw new Error(downloadResponse.error_message || '음악 다운로드에 실패했습니다.');
|
throw new Error(downloadResponse.error_message || '음악 다운로드에 실패했습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
setAudioUrl(downloadResponse.file_url);
|
setAudioUrl(downloadResponse.song_result_url);
|
||||||
|
setSongTaskId(downloadResponse.task_id);
|
||||||
setStatus('complete');
|
setStatus('complete');
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
|
|
@ -142,7 +148,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
};
|
};
|
||||||
|
|
||||||
const regenerateSongOnly = async (currentLyrics: string, currentRetryCount: number) => {
|
const regenerateSongOnly = async (currentLyrics: string, currentRetryCount: number) => {
|
||||||
if (!businessInfo) return;
|
if (!businessInfo || !imageTaskId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
|
|
@ -153,7 +159,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
'어쿠스틱': 'acoustic',
|
'어쿠스틱': 'acoustic',
|
||||||
};
|
};
|
||||||
|
|
||||||
const songResponse = await generateSong({
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
genre: genreMap[selectedGenre] || 'pop',
|
genre: genreMap[selectedGenre] || 'pop',
|
||||||
language,
|
language,
|
||||||
lyrics: currentLyrics,
|
lyrics: currentLyrics,
|
||||||
|
|
@ -163,8 +169,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
|
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
saveToStorage(songResponse.task_id, currentLyrics, 'polling');
|
saveToStorage(songResponse.task_id, songResponse.suno_task_id, currentLyrics, 'polling');
|
||||||
await resumePolling(songResponse.task_id, currentLyrics, currentRetryCount);
|
await resumePolling(songResponse.task_id, songResponse.suno_task_id, currentLyrics, currentRetryCount);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Song regeneration failed:', error);
|
console.error('Song regeneration failed:', error);
|
||||||
|
|
@ -264,40 +270,52 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!imageTaskId) {
|
||||||
|
setErrorMessage('이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('generating_lyric');
|
setStatus('generating_lyric');
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setStatusMessage('가사를 생성하고 있습니다...');
|
setStatusMessage('가사를 생성하고 있습니다...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||||
let lyricResponse = await generateLyric({
|
|
||||||
|
// 1. 가사 생성 요청
|
||||||
|
const lyricResponse = await generateLyric({
|
||||||
customer_name: businessInfo.customer_name,
|
customer_name: businessInfo.customer_name,
|
||||||
detail_region_info: businessInfo.detail_region_info,
|
detail_region_info: businessInfo.detail_region_info,
|
||||||
language,
|
language,
|
||||||
region: businessInfo.region,
|
region: businessInfo.region,
|
||||||
|
task_id: imageTaskId,
|
||||||
});
|
});
|
||||||
|
|
||||||
let retryCount = 0;
|
if (!lyricResponse.success || !lyricResponse.task_id) {
|
||||||
while (lyricResponse.lyric.includes("I'm sorry") && retryCount < 3) {
|
throw new Error(lyricResponse.error_message || '가사 생성 요청에 실패했습니다.');
|
||||||
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) {
|
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
||||||
throw new Error(lyricResponse.error_message || '가사 생성에 실패했습니다.');
|
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('가사 생성에 실패했습니다. 다시 시도해주세요.');
|
throw new Error('가사 생성에 실패했습니다. 다시 시도해주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
setLyrics(lyricResponse.lyric);
|
setLyrics(lyricDetailResponse.lyric_result);
|
||||||
setShowLyrics(true);
|
setShowLyrics(true);
|
||||||
|
|
||||||
setStatus('generating_song');
|
setStatus('generating_song');
|
||||||
|
|
@ -310,10 +328,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
'어쿠스틱': 'acoustic',
|
'어쿠스틱': 'acoustic',
|
||||||
};
|
};
|
||||||
|
|
||||||
const songResponse = await generateSong({
|
const songResponse = await generateSong(imageTaskId, {
|
||||||
genre: genreMap[selectedGenre] || 'pop',
|
genre: genreMap[selectedGenre] || 'pop',
|
||||||
language,
|
language,
|
||||||
lyrics: lyricResponse.lyric,
|
lyrics: lyricDetailResponse.lyric_result,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!songResponse.success) {
|
if (!songResponse.success) {
|
||||||
|
|
@ -322,9 +340,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
|
|
||||||
setStatus('polling');
|
setStatus('polling');
|
||||||
setStatusMessage('음악을 처리하고 있습니다...');
|
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) {
|
} catch (error) {
|
||||||
console.error('Music generation failed:', error);
|
console.error('Music generation failed:', error);
|
||||||
|
|
@ -554,9 +572,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({ onBack, onNext,
|
||||||
{/* Bottom Button */}
|
{/* Bottom Button */}
|
||||||
<div className="bottom-button-container">
|
<div className="bottom-button-container">
|
||||||
<button
|
<button
|
||||||
onClick={onNext}
|
onClick={() => songTaskId && onNext(songTaskId)}
|
||||||
disabled={status !== 'complete'}
|
disabled={status !== 'complete' || !songTaskId}
|
||||||
className={`btn-primary ${status !== 'complete' ? 'disabled' : ''}`}
|
className={`btn-primary ${status !== 'complete' || !songTaskId ? 'disabled' : ''}`}
|
||||||
>
|
>
|
||||||
영상 생성하기
|
영상 생성하기
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,39 @@ export interface LyricGenerateRequest {
|
||||||
detail_region_info: string;
|
detail_region_info: string;
|
||||||
language: string;
|
language: string;
|
||||||
region: string;
|
region: string;
|
||||||
|
task_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 가사 생성 응답
|
// 가사 생성 응답
|
||||||
export interface LyricGenerateResponse {
|
export interface LyricGenerateResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
lyric: string;
|
task_id: string;
|
||||||
language: string;
|
lyric?: string;
|
||||||
prompt_used: string;
|
language?: string;
|
||||||
|
prompt_used?: string;
|
||||||
error_message: string | null;
|
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 {
|
export interface SongGenerateRequest {
|
||||||
genre: string;
|
genre: string;
|
||||||
|
|
@ -56,6 +78,7 @@ export interface SongGenerateRequest {
|
||||||
export interface SongGenerateResponse {
|
export interface SongGenerateResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
|
suno_task_id: string;
|
||||||
message: string;
|
message: string;
|
||||||
error_message: string | null;
|
error_message: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -79,12 +102,75 @@ export interface SongStatusResponse {
|
||||||
// 노래 다운로드 응답
|
// 노래 다운로드 응답
|
||||||
export interface SongDownloadResponse {
|
export interface SongDownloadResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
status: string;
|
||||||
message: string;
|
message: string;
|
||||||
file_path: string;
|
store_name: string;
|
||||||
file_url: string;
|
region: string;
|
||||||
|
detail_region_info: string;
|
||||||
|
task_id: string;
|
||||||
|
language: string;
|
||||||
|
song_result_url: string;
|
||||||
|
created_at: string;
|
||||||
error_message: string | null;
|
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> = {
|
export const LANGUAGE_MAP: Record<string, string> = {
|
||||||
'한국어': 'Korean',
|
'한국어': 'Korean',
|
||||||
|
|
|
||||||
281
src/utils/api.ts
281
src/utils/api.ts
|
|
@ -2,10 +2,17 @@ import {
|
||||||
CrawlingResponse,
|
CrawlingResponse,
|
||||||
LyricGenerateRequest,
|
LyricGenerateRequest,
|
||||||
LyricGenerateResponse,
|
LyricGenerateResponse,
|
||||||
|
LyricStatusResponse,
|
||||||
|
LyricDetailResponse,
|
||||||
SongGenerateRequest,
|
SongGenerateRequest,
|
||||||
SongGenerateResponse,
|
SongGenerateResponse,
|
||||||
SongStatusResponse,
|
SongStatusResponse,
|
||||||
SongDownloadResponse,
|
SongDownloadResponse,
|
||||||
|
VideoGenerateResponse,
|
||||||
|
VideoStatusResponse,
|
||||||
|
VideoDownloadResponse,
|
||||||
|
ImageUrlItem,
|
||||||
|
ImageUploadResponse,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
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();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 노래 생성 API
|
// 가사 상태 조회 API
|
||||||
export async function generateSong(request: SongGenerateRequest): Promise<SongGenerateResponse> {
|
export async function getLyricStatus(taskId: string): Promise<LyricStatusResponse> {
|
||||||
const response = await fetch(`${API_URL}/song/generate`, {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -60,9 +133,9 @@ export async function generateSong(request: SongGenerateRequest): Promise<SongGe
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 노래 상태 확인 API
|
// 노래 상태 확인 API (suno_task_id 사용)
|
||||||
export async function getSongStatus(taskId: string): Promise<SongStatusResponse> {
|
export async function getSongStatus(sunoTaskId: string): Promise<SongStatusResponse> {
|
||||||
const response = await fetch(`${API_URL}/song/status/${taskId}`, {
|
const response = await fetch(`${API_URL}/song/status/${sunoTaskId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -92,51 +165,165 @@ const POLL_INTERVAL = 5000; // 5초
|
||||||
|
|
||||||
export async function waitForSongComplete(
|
export async function waitForSongComplete(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
sunoTaskId: string,
|
||||||
onStatusChange?: (status: string) => void
|
onStatusChange?: (status: string) => void
|
||||||
): Promise<SongDownloadResponse> {
|
): Promise<SongDownloadResponse> {
|
||||||
return new Promise((resolve, reject) => {
|
const startTime = Date.now();
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
const pollInterval = setInterval(async () => {
|
// 재귀적으로 폴링하는 방식으로 변경 (async/await 제대로 동작)
|
||||||
try {
|
const poll = async (): Promise<SongDownloadResponse> => {
|
||||||
// 2분 타임아웃 체크
|
// 2분 타임아웃 체크
|
||||||
if (Date.now() - startTime > POLL_TIMEOUT) {
|
if (Date.now() - startTime > POLL_TIMEOUT) {
|
||||||
clearInterval(pollInterval);
|
throw new Error('TIMEOUT');
|
||||||
reject(new Error('TIMEOUT'));
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusResponse = await getSongStatus(taskId);
|
try {
|
||||||
onStatusChange?.(statusResponse.status);
|
// 상태 확인은 suno_task_id 사용
|
||||||
|
const statusResponse = await getSongStatus(sunoTaskId);
|
||||||
|
onStatusChange?.(statusResponse.status);
|
||||||
|
|
||||||
// status가 "SUCCESS" (대문자)인 경우 완료
|
// status가 "SUCCESS" (대문자)인 경우 완료
|
||||||
if (statusResponse.status === 'SUCCESS' && statusResponse.success) {
|
if (statusResponse.status === 'SUCCESS' && statusResponse.success) {
|
||||||
clearInterval(pollInterval);
|
// 다운로드는 task_id 사용
|
||||||
|
const downloadResponse = await downloadSong(taskId);
|
||||||
// clips에서 첫 번째 오디오 URL 가져오기
|
return downloadResponse;
|
||||||
if (statusResponse.clips && statusResponse.clips.length > 0) {
|
} else if (statusResponse.status === 'FAILED' || statusResponse.status === 'failed') {
|
||||||
const clip = statusResponse.clips[0];
|
throw new Error('Song generation failed');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}, 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