init commit

master
Ubuntu 2025-12-09 23:46:28 +00:00
commit 5cd2a1b6dc
14 changed files with 1113 additions and 0 deletions

24
.gitignore vendored Normal file
View File

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

556
App.tsx Normal file
View File

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

20
README.md Normal file
View File

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

View File

@ -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>
);
};

View File

@ -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>
);
};

83
index.html Normal file
View File

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

15
index.tsx Normal file
View File

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

5
metadata.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "Castad",
"description": "Castad - Generate stunning AI marketing videos for your business using lyrics and music.",
"requestFramePermissions": []
}

25
package.json Normal file
View File

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

79
services/apiService.ts Normal file
View File

@ -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 [];
}
};

56
services/azureStorage.ts Normal file
View File

@ -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.");
}
};

29
tsconfig.json Normal file
View File

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

49
types.ts Normal file
View File

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

23
vite.config.ts Normal file
View File

@ -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, '.'),
}
}
};
});