ado3-front-prototype/App.tsx

557 lines
21 KiB
TypeScript

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<MarketingFormData>({
customer_name: '',
region: '',
detail_region_info: '',
attribute: {
genre: 'k-pop',
vocal: 'clear',
tempo: '130 bp',
mood: 'joyful'
},
imageUrls: []
});
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
// App Logic State
const [isUploading, setIsUploading] = useState(false);
const [submissionStep, setSubmissionStep] = useState<string>('');
// Job Management State
const [jobs, setJobs] = useState<VideoJob[]>([]);
const [activeJobId, setActiveJobId] = useState<string | null>(null);
// History State
const [videoHistory, setVideoHistory] = useState<VideoHistoryItem[]>([]);
// 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<VideoJob>) => {
setJobs(prevJobs => prevJobs.map(job =>
job.id === id ? { ...job, ...updates } : job
));
};
const fileInputRef = useRef<HTMLInputElement>(null);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="min-h-screen relative font-sans text-gray-100 selection:bg-accent selection:text-white pb-20">
{/* Background */}
<div
className="fixed inset-0 z-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: 'url("https://picsum.photos/1920/1080?grayscale&blur=2")',
filter: 'brightness(0.4)'
}}
/>
<div className="fixed inset-0 z-0 bg-gradient-to-br from-blue-900/30 via-transparent to-purple-900/30 animate-gradient-x" />
{/* Main Layout */}
<div className="relative z-10 flex flex-col items-center min-h-screen pt-12 px-4">
{/* Header */}
<div className="mb-10 text-center cursor-pointer" onClick={handleCreateNew}>
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-tr from-cyan-400 to-blue-600 rounded-xl mb-4 shadow-lg shadow-cyan-500/20">
<Sparkles size={24} className="text-white" />
</div>
<h1 className="text-4xl font-serif font-bold text-white mb-2">Castad</h1>
<p className="text-gray-400">Next-Gen Marketing Video Generator</p>
</div>
{/* View Switcher: Form OR Tracker */}
{activeJob ? (
<div className="w-full flex flex-col items-center animate-in fade-in slide-in-from-bottom-8 duration-500">
<ProcessTracker job={activeJob} />
<div className="mt-8">
<button
onClick={handleCreateNew}
className="flex items-center gap-2 px-6 py-3 bg-white/10 hover:bg-white/20 border border-white/20 rounded-full transition-all text-sm font-medium"
>
<Plus size={16} /> Create Another
</button>
</div>
</div>
) : (
<form
onSubmit={handleSubmit}
className="w-full max-w-4xl bg-glass backdrop-blur-xl border border-glassBorder rounded-2xl p-8 shadow-2xl animate-in fade-in zoom-in-95 duration-500"
>
{/* Business Info */}
<div className="space-y-6 mb-8">
<h3 className="text-lg font-medium text-cyan-300 border-b border-white/10 pb-2">Business Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
<Building2 size={16} /> Store Name
</label>
<input
type="text"
name="customer_name"
value={formData.customer_name}
onChange={handleInputChange}
placeholder="오블로모프"
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all placeholder:text-gray-600"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
<MapPin size={16} /> Region
</label>
<input
type="text"
name="region"
value={formData.region}
onChange={handleInputChange}
placeholder="군산"
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all placeholder:text-gray-600"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-300">Detailed Region Info</label>
<input
type="text"
name="detail_region_info"
value={formData.detail_region_info}
onChange={handleInputChange}
placeholder="전북 군산시 절골길 16"
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all placeholder:text-gray-600"
required
/>
</div>
</div>
{/* Attributes Section */}
<div className="space-y-8 mb-8">
<h3 className="text-lg font-medium text-cyan-300 border-b border-white/10 pb-2">Vibe & Style</h3>
{/* 1. Genre */}
<div className="space-y-3">
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
<Music2 size={16} /> Genre
</label>
<div className="flex flex-wrap gap-2">
{ATTRIBUTE_OPTIONS.genres.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleAttributeChange('genre', opt.value)}
className={`
px-4 py-2 rounded-full text-sm font-medium border transition-all
${formData.attribute.genre === opt.value
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-500/20'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-gray-200'
}
`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* 2. Vocal */}
<div className="space-y-3">
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
<Mic2 size={16} /> Vocal Style
</label>
<div className="flex flex-wrap gap-2">
{ATTRIBUTE_OPTIONS.vocals.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleAttributeChange('vocal', opt.value)}
className={`
px-4 py-2 rounded-full text-sm font-medium border transition-all
${formData.attribute.vocal === opt.value
? 'bg-purple-600 border-purple-500 text-white shadow-lg shadow-purple-500/20'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-gray-200'
}
`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* 3. Tempo */}
<div className="space-y-3">
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
<Gauge size={16} /> Tempo
</label>
<div className="flex flex-wrap gap-2">
{ATTRIBUTE_OPTIONS.tempos.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleAttributeChange('tempo', opt.value)}
className={`
px-4 py-2 rounded-full text-sm font-medium border transition-all
${formData.attribute.tempo === opt.value
? 'bg-emerald-600 border-emerald-500 text-white shadow-lg shadow-emerald-500/20'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-gray-200'
}
`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* 4. Mood */}
<div className="space-y-3">
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
<Smile size={16} /> Mood
</label>
<div className="flex flex-wrap gap-2">
{ATTRIBUTE_OPTIONS.moods.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleAttributeChange('mood', opt.value)}
className={`
px-4 py-2 rounded-full text-sm font-medium border transition-all
${formData.attribute.mood === opt.value
? 'bg-orange-600 border-orange-500 text-white shadow-lg shadow-orange-500/20'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-gray-200'
}
`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* Images */}
<div className="space-y-3 mb-8">
<label className="text-sm font-medium text-gray-300 flex items-center gap-2">
<ImagePlus size={16} /> Upload Assets
</label>
<div
className="w-full bg-black/20 border border-dashed border-white/20 rounded-lg p-6 flex flex-col items-center justify-center cursor-pointer hover:bg-white/5 hover:border-blue-400 transition-all"
onClick={() => fileInputRef.current?.click()}
>
<input
type="file"
multiple
accept="image/*"
ref={fileInputRef}
className="hidden"
onChange={handleFileSelect}
/>
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center mb-2">
<UploadCloud className="text-blue-400" size={20} />
</div>
<p className="text-sm text-gray-400">Click to upload images</p>
</div>
{previewUrls.length > 0 && (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide mt-4">
{previewUrls.map((url, idx) => (
<div key={idx} className="relative flex-shrink-0 w-20 h-20 rounded-md overflow-hidden border border-white/10 group">
<img src={url} alt="preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeFile(idx); }}
className="absolute top-1 right-1 bg-black/70 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-500"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isUploading}
className={`
w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white font-bold py-4 rounded-xl shadow-lg
transition-all flex items-center justify-center gap-2
${isUploading ? 'opacity-70 cursor-wait' : 'hover:shadow-blue-500/20 hover:scale-[1.01] active:scale-[0.99]'}
`}
>
{isUploading ? (
<>
<div className="w-5 h-5 border-2 border-white/50 border-t-white rounded-full animate-spin" />
<span>{submissionStep}</span>
</>
) : (
<>
<Sparkles size={20} />
Generate
</>
)}
</button>
</form>
)}
{/* Gallery Section - Always Visible */}
<div className="w-full mt-24">
<VideoGallery videos={videoHistory} />
</div>
</div>
</div>
);
};
export default App;