import React, { useState, useRef, useEffect } from 'react'; import { MarketingFormData, VideoJob, JobStatus, ProcessingStep, VideoHistoryItem } from './types'; import { uploadImagesToAzure } from './services/azureStorage'; import { sendImageManifest, submitJobMetadata, fetchTableData, fetchVideoHistory } from './services/apiService'; import { VideoGallery } from './components/VideoGallery'; import { ProcessTracker } from './components/ProcessTracker'; import { Sparkles, MapPin, Building2, ImagePlus, X, UploadCloud, ArrowRight, Music2, Plus, Mic2, Gauge, Smile } from 'lucide-react'; import { v7 as uuidv7 } from 'uuid'; // Attribute Options Configuration const ATTRIBUTE_OPTIONS = { genres: [ { label: 'K-Pop', value: 'k-pop' }, { label: 'Pop', value: 'pop' }, { label: 'Indie Pop', value: 'indie pop' }, { label: 'Dance', value: 'dance' }, { label: 'Electronic', value: 'electronic' }, { label: 'City Pop', value: 'city pop' }, { label: 'Hip Hop', value: 'hiphop' }, { label: 'R&B', value: 'rnb' }, ], vocals: [ { label: '허스키한', value: 'raspy' }, { label: '맑은', value: 'clear' }, { label: '따뜻한', value: 'warm' }, { label: '밝은', value: 'bright' }, ], tempos: [ { label: 'Indie Pop BPM 110', value: '110 bpm' }, { label: 'Korean Ballad BPM 75', value: '75 bpm' }, { label: 'K-Pop (댄스) BPM 130', value: '130 bpm' }, ], moods: [ { label: '기쁜', value: 'happy' }, { label: '명량한', value: 'jubilant' }, { label: '강렬한', value: 'aggressive' }, { label: '파워풀한', value: 'power' }, { label: '드라마틱한', value: 'dramatic' }, ] }; const App: React.FC = () => { // Form State const [formData, setFormData] = useState({ customer_name: '', region: '', detail_region_info: '', attribute: { genre: 'k-pop', vocal: 'clear', tempo: '130 bp', mood: 'joyful' }, imageUrls: [] }); const [selectedFiles, setSelectedFiles] = useState([]); const [previewUrls, setPreviewUrls] = useState([]); // App Logic State const [isUploading, setIsUploading] = useState(false); const [submissionStep, setSubmissionStep] = useState(''); // Job Management State const [jobs, setJobs] = useState([]); const [activeJobId, setActiveJobId] = useState(null); // History State const [videoHistory, setVideoHistory] = useState([]); // Active job is derived from the jobs array const activeJob = jobs.find(j => j.id === activeJobId) || null; // Fetch History Function const loadHistory = async () => { const history = await fetchVideoHistory(); // Sort by ID descending (newest first) setVideoHistory(history.sort((a, b) => b.id - a.id)); }; // Initial Load useEffect(() => { loadHistory(); }, []); // --- POLLING LOGIC --- useEffect(() => { // Only poll if there are jobs that are PROCESSING const processingJobs = jobs.filter(j => j.status === JobStatus.PROCESSING); if (processingJobs.length === 0) return; const intervalId = setInterval(async () => { // Iterate through all processing jobs for (const job of processingJobs) { try { // STATE MACHINE: Check status based on current step switch (job.currentStep) { case ProcessingStep.LYRICS: { const lyricsData = await fetchTableData(job.id, 'lyrics'); console.log(lyricsData) console.log(lyricsData && lyricsData.status == 'completed') if (lyricsData && lyricsData.status == 'completed') { updateJobState(job.id, { currentStep: ProcessingStep.MUSIC, lyrics: lyricsData.text || lyricsData.content }); } break; } case ProcessingStep.MUSIC: { const songData = await fetchTableData(job.id, 'song'); console.log(songData) console.log(songData && songData.status == 'completed') if (songData && songData.status == 'completed') { updateJobState(job.id, { currentStep: ProcessingStep.VIDEO }); } break; } case ProcessingStep.VIDEO: { const videoData = await fetchTableData(job.id, 'creatomate_result_url'); console.log(videoData) console.log(videoData && videoData.status == 'completed') if (videoData && videoData.status == 'completed') { const resultUrl = typeof videoData.url === 'string' ? videoData.url : (videoData.result || videoData.output); updateJobState(job.id, { status: JobStatus.COMPLETED, currentStep: ProcessingStep.DONE, resultUrl: resultUrl }); // Job Completed: Refresh History loadHistory(); } break; } default: break; } } catch (e) { console.error(`Polling error for job ${job.id}`, e); } } }, 60000); // Poll every 30 seconds return () => clearInterval(intervalId); }, [jobs]); // Re-run effect if jobs array changes (e.g. status updates) const updateJobState = (id: string, updates: Partial) => { setJobs(prevJobs => prevJobs.map(job => job.id === id ? { ...job, ...updates } : job )); }; const fileInputRef = useRef(null); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); }; const handleAttributeChange = (category: keyof MarketingFormData['attribute'], value: string) => { setFormData(prev => ({ ...prev, attribute: { ...prev.attribute, [category]: value } })); }; const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files) { const newFiles = Array.from(e.target.files); setSelectedFiles(prev => [...prev, ...newFiles]); const newPreviews = newFiles.map(file => URL.createObjectURL(file)); setPreviewUrls(prev => [...prev, ...newPreviews]); } }; const removeFile = (index: number) => { setSelectedFiles(prev => prev.filter((_, i) => i !== index)); setPreviewUrls(prev => { URL.revokeObjectURL(prev[index]); return prev.filter((_, i) => i !== index); }); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.customer_name) return; try { setIsUploading(true); // Step 1: Generate Task ID setSubmissionStep('Initializing Task ID...'); const task_id = uuidv7(); // Step 2: Upload Images to Azure setSubmissionStep('Uploading Images to Azure...'); let uploadedUrls: string[] = []; if (selectedFiles.length > 0) { uploadedUrls = await uploadImagesToAzure(selectedFiles, task_id); } // Step 3: Send Image Manifest to API 2 setSubmissionStep('Syncing Image Data...'); const manifest = uploadedUrls.map(url => ({ task_id: task_id, img_url: url })); if (manifest.length > 0) { await sendImageManifest(manifest); } // Step 4: Submit Metadata to API 3 setSubmissionStep('Finalizing Submission...'); const payload = { customer_name: formData.customer_name, region: formData.region, detail_region_info: formData.detail_region_info, task_id: task_id, attribute: formData.attribute }; await submitJobMetadata(payload); // Refresh history immediately after submit to confirm operation loadHistory(); // Step 5: Start Tracking // Create new job object const newJob: VideoJob = { id: task_id, createdAt: Date.now(), data: { ...formData, imageUrls: uploadedUrls }, status: JobStatus.PROCESSING, currentStep: ProcessingStep.LYRICS // Start at Lyrics since upload is done }; setJobs(prev => [newJob, ...prev]); setActiveJobId(task_id); setIsUploading(false); } catch (err) { console.error("Submission error", err); setIsUploading(false); setSubmissionStep('Error occurred.'); alert("Failed to submit job. Check console for details."); } }; const handleCreateNew = () => { setActiveJobId(null); setFormData({ customer_name: '', region: '', detail_region_info: '', attribute: { genre: 'k-pop', vocal: 'clear', tempo: '130 bp', mood: 'joyful' }, imageUrls: [] }); setSelectedFiles([]); setPreviewUrls([]); setSubmissionStep(''); }; return (
{/* Background */}
{/* Main Layout */}
{/* Header */}

Castad

Next-Gen Marketing Video Generator

{/* View Switcher: Form OR Tracker */} {activeJob ? (
) : (
{/* Business Info */}

Business Information

{/* Attributes Section */}

Vibe & Style

{/* 1. Genre */}
{ATTRIBUTE_OPTIONS.genres.map((opt) => ( ))}
{/* 2. Vocal */}
{ATTRIBUTE_OPTIONS.vocals.map((opt) => ( ))}
{/* 3. Tempo */}
{ATTRIBUTE_OPTIONS.tempos.map((opt) => ( ))}
{/* 4. Mood */}
{ATTRIBUTE_OPTIONS.moods.map((opt) => ( ))}
{/* Images */}
fileInputRef.current?.click()} >

Click to upload images

{previewUrls.length > 0 && (
{previewUrls.map((url, idx) => (
preview
))}
)}
{/* Submit Button */}
)} {/* Gallery Section - Always Visible */}
); }; export default App;