345 lines
No EOL
11 KiB
TypeScript
345 lines
No EOL
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { Sparkles, Play, Download, RefreshCw, Loader2, CheckCircle, AlertCircle } from 'lucide-react'
|
|
import { useSettings } from '@/lib/context/SettingsContext'
|
|
import { createWorkflowWithAI } from '@/lib/api/opencode'
|
|
import { generateImage, generateVideo, waitForTaskCompletion } from '@/lib/api/api34ai'
|
|
import { WorkflowNode } from '@/lib/types'
|
|
|
|
const nodeIcons: Record<string, string> = {
|
|
idea: '💡',
|
|
script: '📝',
|
|
image: '🖼️',
|
|
video: '🎬',
|
|
}
|
|
|
|
const nodeLabels: Record<string, string> = {
|
|
idea: 'Idea',
|
|
script: 'Script',
|
|
image: 'Image',
|
|
video: 'Video',
|
|
}
|
|
|
|
export default function CreateTab() {
|
|
const { settings } = useSettings()
|
|
const [idea, setIdea] = useState('')
|
|
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
|
|
const [isExecuting, setIsExecuting] = useState(false)
|
|
const [workflow, setWorkflow] = useState<WorkflowNode[]>([])
|
|
const [currentStep, setCurrentStep] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const handleCreateWorkflow = async () => {
|
|
if (!idea.trim()) {
|
|
setError('Please enter a video idea')
|
|
return
|
|
}
|
|
|
|
if (!settings.opencodeApiKey) {
|
|
setError('Please configure OpenCode API key in Settings')
|
|
return
|
|
}
|
|
|
|
setIsCreatingWorkflow(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const result = await createWorkflowWithAI(
|
|
settings.opencodeApiKey,
|
|
settings.opencodeModel,
|
|
idea
|
|
)
|
|
|
|
const nodes: WorkflowNode[] = result.nodes.map((node, index) => ({
|
|
id: `node-${index}`,
|
|
type: node.type,
|
|
data: {
|
|
prompt: node.prompt,
|
|
status: 'idle',
|
|
model: node.type === 'image'
|
|
? settings.imageModel
|
|
: node.type === 'video'
|
|
? settings.videoModel
|
|
: undefined,
|
|
duration: settings.defaultDuration,
|
|
aspectRatio: settings.defaultAspectRatio,
|
|
},
|
|
position: { x: 100, y: 100 + index * 150 },
|
|
}))
|
|
|
|
setWorkflow(nodes)
|
|
} catch (e: any) {
|
|
setError(e.message || 'Failed to create workflow')
|
|
} finally {
|
|
setIsCreatingWorkflow(false)
|
|
}
|
|
}
|
|
|
|
const handleExecuteWorkflow = async () => {
|
|
if (!settings.api34aiKey) {
|
|
setError('Please configure 34ai API key in Settings')
|
|
return
|
|
}
|
|
|
|
setIsExecuting(true)
|
|
setError(null)
|
|
|
|
try {
|
|
for (const node of workflow) {
|
|
setCurrentStep(nodeLabels[node.type])
|
|
|
|
const updatedNodes = [...workflow]
|
|
const nodeIndex = updatedNodes.findIndex(n => n.id === node.id)
|
|
updatedNodes[nodeIndex] = {
|
|
...node,
|
|
data: { ...node.data, status: 'running' },
|
|
}
|
|
setWorkflow(updatedNodes)
|
|
|
|
try {
|
|
if (node.type === 'image') {
|
|
const result = await generateImage(
|
|
settings.api34aiKey,
|
|
{
|
|
prompt: node.data.prompt,
|
|
model: node.data.model || 'banana_pro_2',
|
|
ratio: node.data.aspectRatio || '1:1',
|
|
resolution: '1K'
|
|
}
|
|
)
|
|
|
|
const imageUrl = await waitForTaskCompletion(
|
|
settings.api34aiKey,
|
|
result.task_id
|
|
)
|
|
|
|
updatedNodes[nodeIndex] = {
|
|
...updatedNodes[nodeIndex],
|
|
data: {
|
|
...updatedNodes[nodeIndex].data,
|
|
status: 'completed',
|
|
result: imageUrl,
|
|
},
|
|
}
|
|
setWorkflow([...updatedNodes])
|
|
}
|
|
else if (node.type === 'video') {
|
|
const videoResult = await generateVideo(
|
|
settings.api34aiKey,
|
|
{
|
|
prompt: node.data.prompt,
|
|
model: node.data.model || 'veo3.1',
|
|
duration: node.data.duration || 4,
|
|
ratio: node.data.aspectRatio || '16:9',
|
|
resolution: '720p',
|
|
quality: 'fast'
|
|
}
|
|
)
|
|
|
|
const videoUrl = await waitForTaskCompletion(
|
|
settings.api34aiKey,
|
|
videoResult.task_id
|
|
)
|
|
|
|
updatedNodes[nodeIndex] = {
|
|
...updatedNodes[nodeIndex],
|
|
data: {
|
|
...updatedNodes[nodeIndex].data,
|
|
status: 'completed',
|
|
result: videoUrl,
|
|
},
|
|
}
|
|
setWorkflow([...updatedNodes])
|
|
}
|
|
else {
|
|
updatedNodes[nodeIndex] = {
|
|
...updatedNodes[nodeIndex],
|
|
data: {
|
|
...updatedNodes[nodeIndex].data,
|
|
status: 'completed',
|
|
},
|
|
}
|
|
setWorkflow([...updatedNodes])
|
|
}
|
|
} catch (nodeError: any) {
|
|
updatedNodes[nodeIndex] = {
|
|
...updatedNodes[nodeIndex],
|
|
data: {
|
|
...updatedNodes[nodeIndex].data,
|
|
status: 'error',
|
|
error: nodeError.message,
|
|
},
|
|
}
|
|
setWorkflow([...updatedNodes])
|
|
throw nodeError
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
setError(e.message || 'Workflow execution failed')
|
|
} finally {
|
|
setIsExecuting(false)
|
|
setCurrentStep(null)
|
|
}
|
|
}
|
|
|
|
const downloadMedia = async (url: string, filename: string) => {
|
|
try {
|
|
const response = await fetch(url)
|
|
const blob = await response.blob()
|
|
const downloadUrl = window.URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = downloadUrl
|
|
link.download = filename
|
|
link.click()
|
|
window.URL.revokeObjectURL(downloadUrl)
|
|
} catch (e) {
|
|
console.error('Download failed:', e)
|
|
}
|
|
}
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case 'running':
|
|
return <Loader2 className="animate-spin text-primary" size={16} />
|
|
case 'completed':
|
|
return <CheckCircle className="text-green-500" size={16} />
|
|
case 'error':
|
|
return <AlertCircle className="text-red-500" size={16} />
|
|
default:
|
|
return <div className="w-4 h-4 rounded-full bg-slate-600" />
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="text-center mb-8">
|
|
<h2 className="text-2xl font-bold text-white mb-2">Create Video from Idea</h2>
|
|
<p className="text-slate-400">Enter your video idea and let AI create the workflow</p>
|
|
</div>
|
|
|
|
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
|
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
Your Video Idea
|
|
</label>
|
|
<textarea
|
|
value={idea}
|
|
onChange={(e) => setIdea(e.target.value)}
|
|
placeholder="E.g., A robot learning to dance at a disco, with neon lights and upbeat music"
|
|
className="w-full h-32 bg-slate-800 border border-slate-600 rounded-lg px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-primary resize-none"
|
|
/>
|
|
|
|
<div className="mt-4 flex gap-3">
|
|
<button
|
|
onClick={handleCreateWorkflow}
|
|
disabled={isCreatingWorkflow}
|
|
className="flex items-center gap-2 px-6 py-2.5 bg-primary hover:bg-primary-dark disabled:bg-slate-600 rounded-lg text-white font-medium transition-colors"
|
|
>
|
|
{isCreatingWorkflow ? (
|
|
<Loader2 className="animate-spin" size={18} />
|
|
) : (
|
|
<Sparkles size={18} />
|
|
)}
|
|
Create Workflow
|
|
</button>
|
|
|
|
{workflow.length > 0 && (
|
|
<button
|
|
onClick={() => setWorkflow([])}
|
|
className="flex items-center gap-2 px-6 py-2.5 bg-slate-700 hover:bg-slate-600 rounded-lg text-white font-medium transition-colors"
|
|
>
|
|
<RefreshCw size={18} />
|
|
Reset
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4 text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{currentStep && (
|
|
<div className="bg-primary/10 border border-primary rounded-lg p-4 text-primary flex items-center gap-2">
|
|
<Loader2 className="animate-spin" size={18} />
|
|
Executing: {currentStep}...
|
|
</div>
|
|
)}
|
|
|
|
{workflow.length > 0 && (
|
|
<div className="bg-surface rounded-xl p-6 border border-slate-700">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-white">Workflow</h3>
|
|
<button
|
|
onClick={handleExecuteWorkflow}
|
|
disabled={isExecuting}
|
|
className="flex items-center gap-2 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:bg-slate-600 rounded-lg text-white font-medium transition-colors"
|
|
>
|
|
{isExecuting ? (
|
|
<Loader2 className="animate-spin" size={18} />
|
|
) : (
|
|
<Play size={18} />
|
|
)}
|
|
{isExecuting ? 'Running...' : 'Run Pipeline'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{workflow.map((node, index) => (
|
|
<div
|
|
key={node.id}
|
|
className="flex items-start gap-4 p-4 bg-slate-800 rounded-lg border border-slate-600"
|
|
>
|
|
<div className="flex items-center gap-3 min-w-[120px]">
|
|
<span className="text-2xl">{nodeIcons[node.type]}</span>
|
|
<span className="font-medium text-white">{nodeLabels[node.type]}</span>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<p className="text-slate-300 text-sm">{node.data.prompt}</p>
|
|
|
|
{node.data.result && (
|
|
<div className="mt-3">
|
|
{node.type === 'image' && (
|
|
<img
|
|
src={node.data.result}
|
|
alt="Generated"
|
|
className="max-h-40 rounded-lg border border-slate-600"
|
|
/>
|
|
)}
|
|
{node.type === 'video' && (
|
|
<video
|
|
src={node.data.result}
|
|
controls
|
|
className="max-h-40 rounded-lg border border-slate-600"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{getStatusIcon(node.data.status)}
|
|
{node.data.status === 'completed' && node.data.result && (
|
|
<button
|
|
onClick={() => downloadMedia(
|
|
node.data.result!,
|
|
`${node.type}-${Date.now()}.${node.type === 'video' ? 'mp4' : 'jpg'}`
|
|
)}
|
|
className="p-2 hover:bg-slate-700 rounded-lg transition-colors"
|
|
title="Download"
|
|
>
|
|
<Download size={18} className="text-slate-400 hover:text-white" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
} |