557 lines
21 KiB
TypeScript
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;
|