init commit
commit
5cd2a1b6dc
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,556 @@
|
|||
|
||||
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;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1LbQRtjumcLDp4knwfpkaxKoHbZPbn_zE
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
|
||||
import React from 'react';
|
||||
import { ProcessingStep, VideoJob } from '../types';
|
||||
import { Loader2, CheckCircle2, Music, Video, FileText, UploadCloud } from 'lucide-react';
|
||||
|
||||
interface ProcessTrackerProps {
|
||||
job: VideoJob;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ id: ProcessingStep.UPLOAD, label: "Upload", icon: UploadCloud },
|
||||
{ id: ProcessingStep.LYRICS, label: "Lyrics", icon: FileText },
|
||||
{ id: ProcessingStep.MUSIC, label: "Music", icon: Music },
|
||||
{ id: ProcessingStep.VIDEO, label: "Video", icon: Video },
|
||||
];
|
||||
|
||||
export const ProcessTracker: React.FC<ProcessTrackerProps> = ({ job }) => {
|
||||
const getCurrentStepIndex = () => {
|
||||
if (job.currentStep === ProcessingStep.DONE) return steps.length;
|
||||
return steps.findIndex(s => s.id === job.currentStep);
|
||||
};
|
||||
|
||||
const currentIndex = getCurrentStepIndex();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto p-6 bg-glass backdrop-blur-xl border border-glassBorder rounded-2xl shadow-2xl">
|
||||
<h3 className="text-xl font-serif font-bold text-center mb-6 text-white">
|
||||
Creating Magic for {job.data.customer_name}
|
||||
</h3>
|
||||
|
||||
<div className="relative flex justify-between items-center mb-8">
|
||||
{/* Connecting Line */}
|
||||
<div className="absolute top-1/2 left-0 w-full h-1 bg-gray-700 -z-10 transform -translate-y-1/2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-accent transition-all duration-700 ease-out"
|
||||
style={{ width: `${(currentIndex / (steps.length - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === currentIndex;
|
||||
const isCompleted = index < currentIndex;
|
||||
const Icon = step.icon;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={`
|
||||
w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all duration-500
|
||||
${isCompleted
|
||||
? 'bg-accent border-accent text-white scale-110'
|
||||
: isActive
|
||||
? 'bg-gray-900 border-blue-400 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
|
||||
: 'bg-gray-900 border-gray-600 text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? <CheckCircle2 size={24} /> : isActive ? <Loader2 size={24} className="animate-spin" /> : <Icon size={20} />}
|
||||
</div>
|
||||
<span className={`
|
||||
text-xs uppercase tracking-wider transition-colors duration-300 text-center
|
||||
${isActive ? 'text-blue-300 font-bold drop-shadow-[0_0_5px_rgba(147,197,253,0.5)]' : ''}
|
||||
${isCompleted ? 'text-white font-semibold' : ''}
|
||||
${!isActive && !isCompleted ? 'text-gray-400 font-medium' : ''}
|
||||
`}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-center h-8">
|
||||
<p className="text-blue-200 animate-pulse text-sm font-medium">
|
||||
{job.currentStep !== ProcessingStep.DONE ? `Current Step: ${job.currentStep}` : 'Finalizing output...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
|
||||
import React from 'react';
|
||||
import { VideoHistoryItem } from '../types';
|
||||
import { Calendar, Film } from 'lucide-react';
|
||||
|
||||
interface VideoGalleryProps {
|
||||
videos: VideoHistoryItem[];
|
||||
}
|
||||
|
||||
export const VideoGallery: React.FC<VideoGalleryProps> = ({ videos }) => {
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto px-4 py-12">
|
||||
<h2 className="text-3xl font-serif font-bold mb-8 text-white border-b border-white/10 pb-4">
|
||||
Your Creations
|
||||
</h2>
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p>No videos found. Create your first campaign above!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{videos.map((video) => (
|
||||
<div
|
||||
key={video.id}
|
||||
className="group relative bg-glass backdrop-blur-md border border-glassBorder rounded-xl overflow-hidden hover:border-blue-500/50 transition-all duration-300 hover:shadow-2xl hover:-translate-y-1"
|
||||
>
|
||||
{/* Video Thumbnail Area */}
|
||||
<div className="relative aspect-video bg-gray-900 overflow-hidden">
|
||||
{video.result_movie_url ? (
|
||||
<video
|
||||
src={video.result_movie_url}
|
||||
className="w-full h-full object-cover"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-800 text-gray-500">
|
||||
<Film className="opacity-20" size={48} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-400 uppercase tracking-wider mb-1">Created At</span>
|
||||
<div className="flex items-center text-gray-200 text-sm font-mono">
|
||||
<Calendar size={14} className="mr-2 text-blue-400" />
|
||||
{video.created_at}
|
||||
</div>
|
||||
</div>
|
||||
{video.status && (
|
||||
<div className={`px-2 py-1 rounded text-xs font-bold uppercase ${
|
||||
video.status.toLowerCase() === 'completed' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{video.status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Castad - Marketing Generator</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
serif: ['Playfair Display', 'serif'],
|
||||
},
|
||||
colors: {
|
||||
glass: "rgba(255, 255, 255, 0.1)",
|
||||
glassBorder: "rgba(255, 255, 255, 0.2)",
|
||||
primary: "#3b82f6", // Blue-500
|
||||
accent: "#8b5cf6", // Violet-500
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'gradient-x': 'gradient-x 15s ease infinite',
|
||||
},
|
||||
keyframes: {
|
||||
'gradient-x': {
|
||||
'0%, 100%': {
|
||||
'background-size': '200% 200%',
|
||||
'background-position': 'left center',
|
||||
},
|
||||
'50%': {
|
||||
'background-size': '200% 200%',
|
||||
'background-position': 'right center',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #0f172a;
|
||||
color: #fff;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
|
||||
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.31.0",
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.1/",
|
||||
"react/": "https://aistudiocdn.com/react@^19.2.1/",
|
||||
"react": "https://aistudiocdn.com/react@^19.2.1",
|
||||
"uuid": "https://cdn.jsdelivr.net/npm/uuid@11.0.3/+esm",
|
||||
"@azure/storage-blob": "https://esm.sh/@azure/storage-blob@12.17.0?bundle"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "Castad",
|
||||
"description": "Castad - Generate stunning AI marketing videos for your business using lyrics and music.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "castad",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.555.0",
|
||||
"@google/genai": "^1.31.0",
|
||||
"react-dom": "^19.2.1",
|
||||
"react": "^19.2.1",
|
||||
"uuid": "11.0.3",
|
||||
"@azure/storage-blob": "12.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
|
||||
import { VideoHistoryItem } from '../types';
|
||||
|
||||
const API_ENDPOINT_1="https://o2odev.app.n8n.cloud/webhook/get_video_list"
|
||||
const API_ENDPOINT_2="https://o2odev.app.n8n.cloud/webhook/upload_image_path"
|
||||
const API_ENDPOINT_3="https://o2odev.app.n8n.cloud/webhook/ado3"
|
||||
const API_ENDPOINT_4="https://o2odev.app.n8n.cloud/webhook/mysql-api"
|
||||
|
||||
export const fetchVideoHistory = async (): Promise<VideoHistoryItem[]> => {
|
||||
try {
|
||||
const response = await fetch(API_ENDPOINT_1);
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch history");
|
||||
return [];
|
||||
}
|
||||
const data = await response.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching video history:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const sendImageManifest = async (manifest: { task_id: string; img_url: string }[]) => {
|
||||
const url = API_ENDPOINT_2;
|
||||
|
||||
console.log(manifest)
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(manifest)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send image manifest: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const submitJobMetadata = async (data: any) => {
|
||||
const url = API_ENDPOINT_3;
|
||||
|
||||
console.log(data)
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to submit job metadata: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const fetchTableData = async (task_id: string, tableName: 'lyrics' | 'song' | 'creatomate_result_url') => {
|
||||
const url = API_ENDPOINT_4;
|
||||
|
||||
try {
|
||||
// Calls API 4 with ?task_id=UUID&table=TABLE_NAME
|
||||
const response = await fetch(`${url}?task_id=${task_id}&table=${tableName}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If 404, it likely means that step isn't done yet
|
||||
return null;
|
||||
}
|
||||
var result = await response.json()
|
||||
console.log(result[0])
|
||||
// The API seems to return an array, grab the first item if so
|
||||
return Array.isArray(result) ? result[0] : result;
|
||||
} catch (error) {
|
||||
//console.error(`Error fetching table ${tableName}:`, error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
|
||||
import { BlobServiceClient, BlockBlobClient} from '@azure/storage-blob';
|
||||
|
||||
export const uploadImagesToAzure = async (files: File[], task_idx: string): Promise<string[]> => {
|
||||
// const sasUrl = process.env.AZURE_STORAGE_SAS_URL;
|
||||
// // Default to 'castad-data' if not set, or read from env
|
||||
// const rootPath = process.env.AZURE_UPLOAD_ROOT || "castad-data";
|
||||
// const containerName = "castad-uploads"; // Ensure this matches your SAS token permissions or container
|
||||
|
||||
// if (!sasUrl) {
|
||||
// console.warn("AZURE_STORAGE_SAS_URL is missing in .env. Returning mock URLs.");
|
||||
// return files.map((_, i) => `https://mock-azure.com/${rootPath}/${task_idx}/img/${i}.jpg`);
|
||||
// }
|
||||
const AZURE_SAS_TOKEN="sv=2024-11-04&ss=bfqt&srt=sco&sp=rwdlacupiytfx&se=2027-03-12T17:32:56Z&st=2025-12-08T09:17:56Z&spr=https,http&sig=J0uF91mTBpSUVSk0O%2FG2IXmebCDRMYp1%2BNOtBVpcOKE%3D"
|
||||
const AZURE_SERVICE_PATH="https://ado2mediastoragepublic.blob.core.windows.net/"
|
||||
// // const rootPath = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx"
|
||||
// // const blobPath =
|
||||
try {
|
||||
|
||||
// const sas_url = `https://ado2mediastoragepublic.blob.core.windows.net/?sv=2024-11-04&ss=bfqt&srt=sco&sp=rwdlacupiytfx&se=2027-03-12T17:32:56Z&st=2025-12-08T09:17:56Z&spr=https,http&sig=J0uF91mTBpSUVSk0O%2FG2IXmebCDRMYp1%2BNOtBVpcOKE%3D`;
|
||||
// const blobServiceClient = new BlobServiceClient(AZURE_SERVICE_PATH,);
|
||||
// const blobContainerClient = blobServiceClient.getContainerClient("ado2-media-public-access")
|
||||
// const blockBlobClient = new BlockBlobClient
|
||||
|
||||
const uploadPromises = files.map(async (file, index) => {
|
||||
const extension = file.name.split('.').pop() || 'jpg';
|
||||
// Path format: ROOT/task_id/img/uploadNumber.extension
|
||||
|
||||
const blobName = `${AZURE_SERVICE_PATH}ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/${task_idx}-img-${index}.${extension}`;
|
||||
const sasUrl = `${blobName}?${AZURE_SAS_TOKEN}`
|
||||
// // console.log(`\nBlobUrl = ${sasUrl}\n`);
|
||||
// const blockBlobClient = blobContainerClient.getBlockBlobClient(blobName);
|
||||
|
||||
// await blockBlobClient.uploadData(file, {
|
||||
// blobHTTPHeaders: { blobContentType: file.type }
|
||||
// });
|
||||
// console.log(blockBlobClient.url)
|
||||
// return blockBlobClient.url;
|
||||
// var data = new FormData()
|
||||
// data.append('file', file)
|
||||
fetch(sasUrl, {
|
||||
headers: {"Content-Type": file.type,
|
||||
"x-ms-blob-type" : "BlockBlob"},
|
||||
method: 'PUT',
|
||||
body: file // FormData 객체를 직접 body에 전달하면 Content-Type이 자동으로 설정됨 (multipart/form-data)
|
||||
})
|
||||
console.log(blobName)
|
||||
return blobName
|
||||
});
|
||||
return await Promise.all(uploadPromises);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Azure Upload Error:", error);
|
||||
throw new Error("Failed to upload images to Azure.");
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
|
||||
export enum JobStatus {
|
||||
PENDING = 'PENDING',
|
||||
PROCESSING = 'PROCESSING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED'
|
||||
}
|
||||
|
||||
export enum ProcessingStep {
|
||||
UPLOAD = 'Uploading Assets',
|
||||
LYRICS = 'Generating Lyrics',
|
||||
MUSIC = 'Composing Music',
|
||||
VIDEO = 'Synthesizing Video',
|
||||
DONE = 'Finalizing'
|
||||
}
|
||||
|
||||
export interface MarketingFormData {
|
||||
task_idx?: string;
|
||||
customer_name: string; // Maps to businessName
|
||||
region: string;
|
||||
detail_region_info: string;
|
||||
attribute: {
|
||||
genre: string;
|
||||
vocal: string;
|
||||
tempo: string;
|
||||
mood: string;
|
||||
};
|
||||
imageUrls: string[];
|
||||
}
|
||||
|
||||
export interface VideoJob {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
data: MarketingFormData;
|
||||
status: JobStatus;
|
||||
currentStep: ProcessingStep;
|
||||
resultUrl?: string;
|
||||
lyrics?: string;
|
||||
}
|
||||
|
||||
export interface VideoHistoryItem {
|
||||
id: number;
|
||||
input_history_id: number;
|
||||
song_id: number;
|
||||
task_id: string;
|
||||
status: string;
|
||||
result_movie_url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Reference in New Issue