front 작업 중 .

main
hbyang 2025-12-29 17:56:57 +09:00
parent ecbd81d091
commit 24d61f925a
8 changed files with 797 additions and 125 deletions

View File

@ -4,7 +4,8 @@
"Bash(mkdir:*)", "Bash(mkdir:*)",
"Bash(mv:*)", "Bash(mv:*)",
"Bash(rmdir:*)", "Bash(rmdir:*)",
"Bash(rm:*)" "Bash(rm:*)",
"Bash(npm run build:*)"
] ]
} }
} }

View File

@ -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
===================================================== */ ===================================================== */

View File

@ -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>

View File

@ -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,22 +266,64 @@ 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">
{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>
) : videoUrl ? (
/* Video Player */
<video
ref={videoRef}
src={videoUrl}
className="video-player"
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
onClick={togglePlayPause}
/>
) : (
<div className="video-pattern"></div> <div className="video-pattern"></div>
)}
{/* Video Player Controls */} {/* Video Player Controls - only show when video is ready */}
{videoStatus === 'complete' && videoUrl && (
<div className="video-controls"> <div className="video-controls">
<div className="video-controls-inner"> <div className="video-controls-inner">
<button className="video-play-btn"> <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="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> </button>
<div className="video-progress"> <div className="video-progress">
<div className="video-progress-fill" style={{ width: '40%' }}></div> <div className="video-progress-fill" style={{ width: `${progress}%` }}></div>
</div> </div>
</div> </div>
</div> </div>
)}
</div> </div>
{/* Tags */} {/* Tags - only show when complete */}
{videoStatus === 'complete' && (
<div className="tags-container"> <div className="tags-container">
{['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => ( {['AI 최적화', '색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => (
<span key={tag} className="tag-dot"> <span key={tag} className="tag-dot">
@ -71,6 +331,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({ onBack }) => {
</span> </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>

View File

@ -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>

View File

@ -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>

View File

@ -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',

View File

@ -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 {
// 상태 확인은 suno_task_id 사용
const statusResponse = await getSongStatus(sunoTaskId);
onStatusChange?.(statusResponse.status); onStatusChange?.(statusResponse.status);
// status가 "SUCCESS" (대문자)인 경우 완료 // status가 "SUCCESS" (대문자)인 경우 완료
if (statusResponse.status === 'SUCCESS' && statusResponse.success) { if (statusResponse.status === 'SUCCESS' && statusResponse.success) {
clearInterval(pollInterval); // 다운로드는 task_id 사용
// 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); const downloadResponse = await downloadSong(taskId);
resolve(downloadResponse); return downloadResponse;
}
} else if (statusResponse.status === 'FAILED' || statusResponse.status === 'failed') { } else if (statusResponse.status === 'FAILED' || statusResponse.status === 'failed') {
clearInterval(pollInterval); throw new Error('Song generation failed');
reject(new Error('Song generation failed'));
} }
// PENDING, PROCESSING 등은 계속 폴링
// PENDING, PROCESSING 등은 대기 후 재시도
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
return poll();
} catch (error) { } catch (error) {
clearInterval(pollInterval); throw error;
reject(error);
} }
}, POLL_INTERVAL); };
});
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();
} }