469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
import {
|
|
BaseBoxShapeUtil,
|
|
Geometry2d,
|
|
HTMLContainer,
|
|
Rectangle2d,
|
|
TLBaseShape,
|
|
} from "tldraw"
|
|
import React, { useState } from "react"
|
|
import { getRunPodVideoConfig } from "@/lib/clientConfig"
|
|
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
|
|
|
// Type for RunPod job response
|
|
interface RunPodJobResponse {
|
|
id?: string
|
|
status?: 'IN_QUEUE' | 'IN_PROGRESS' | 'STARTING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'
|
|
output?: {
|
|
video_url?: string
|
|
url?: string
|
|
[key: string]: any
|
|
} | string
|
|
error?: string
|
|
}
|
|
|
|
type IVideoGen = TLBaseShape<
|
|
"VideoGen",
|
|
{
|
|
w: number
|
|
h: number
|
|
prompt: string
|
|
videoUrl: string | null
|
|
isLoading: boolean
|
|
error: string | null
|
|
duration: number // seconds
|
|
model: string
|
|
tags: string[]
|
|
}
|
|
>
|
|
|
|
export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|
static override type = "VideoGen" as const
|
|
|
|
// Video generation theme color: Purple
|
|
static readonly PRIMARY_COLOR = "#8B5CF6"
|
|
|
|
getDefaultProps(): IVideoGen['props'] {
|
|
return {
|
|
w: 500,
|
|
h: 450,
|
|
prompt: "",
|
|
videoUrl: null,
|
|
isLoading: false,
|
|
error: null,
|
|
duration: 3,
|
|
model: "wan2.1-i2v",
|
|
tags: ['video', 'ai-generated']
|
|
}
|
|
}
|
|
|
|
getGeometry(shape: IVideoGen): Geometry2d {
|
|
return new Rectangle2d({
|
|
width: shape.props.w,
|
|
height: shape.props.h,
|
|
isFilled: true,
|
|
})
|
|
}
|
|
|
|
component(shape: IVideoGen) {
|
|
const [prompt, setPrompt] = useState(shape.props.prompt)
|
|
const [isGenerating, setIsGenerating] = useState(shape.props.isLoading)
|
|
const [error, setError] = useState<string | null>(shape.props.error)
|
|
const [videoUrl, setVideoUrl] = useState<string | null>(shape.props.videoUrl)
|
|
const [isMinimized, setIsMinimized] = useState(false)
|
|
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
|
|
|
const handleGenerate = async () => {
|
|
if (!prompt.trim()) {
|
|
setError("Please enter a prompt")
|
|
return
|
|
}
|
|
|
|
// Check RunPod config
|
|
const runpodConfig = getRunPodVideoConfig()
|
|
if (!runpodConfig) {
|
|
setError("RunPod video endpoint not configured. Please set VITE_RUNPOD_API_KEY and VITE_RUNPOD_VIDEO_ENDPOINT_ID in your .env file.")
|
|
return
|
|
}
|
|
|
|
console.log('🎬 VideoGen: Starting generation with prompt:', prompt)
|
|
setIsGenerating(true)
|
|
setError(null)
|
|
|
|
// Update shape to show loading state
|
|
this.editor.updateShape({
|
|
id: shape.id,
|
|
type: shape.type,
|
|
props: { ...shape.props, isLoading: true, error: null }
|
|
})
|
|
|
|
try {
|
|
const { apiKey, endpointId } = runpodConfig
|
|
|
|
// Submit job to RunPod
|
|
console.log('🎬 VideoGen: Submitting to RunPod endpoint:', endpointId)
|
|
const runUrl = `https://api.runpod.ai/v2/${endpointId}/run`
|
|
|
|
const response = await fetch(runUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
input: {
|
|
prompt: prompt,
|
|
duration: shape.props.duration,
|
|
model: shape.props.model
|
|
}
|
|
})
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
throw new Error(`RunPod API error: ${response.status} - ${errorText}`)
|
|
}
|
|
|
|
const jobData = await response.json() as RunPodJobResponse
|
|
console.log('🎬 VideoGen: Job submitted:', jobData.id)
|
|
|
|
if (!jobData.id) {
|
|
throw new Error('No job ID returned from RunPod')
|
|
}
|
|
|
|
// Poll for completion
|
|
const statusUrl = `https://api.runpod.ai/v2/${endpointId}/status/${jobData.id}`
|
|
let attempts = 0
|
|
const maxAttempts = 120 // 4 minutes with 2s intervals (video can take a while)
|
|
|
|
while (attempts < maxAttempts) {
|
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
attempts++
|
|
|
|
const statusResponse = await fetch(statusUrl, {
|
|
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
})
|
|
|
|
if (!statusResponse.ok) {
|
|
console.warn(`🎬 VideoGen: Poll error (attempt ${attempts}):`, statusResponse.status)
|
|
continue
|
|
}
|
|
|
|
const statusData = await statusResponse.json() as RunPodJobResponse
|
|
console.log(`🎬 VideoGen: Poll ${attempts}/${maxAttempts}, status:`, statusData.status)
|
|
|
|
if (statusData.status === 'COMPLETED') {
|
|
// Extract video URL from output
|
|
let url = ''
|
|
if (typeof statusData.output === 'string') {
|
|
url = statusData.output
|
|
} else if (statusData.output?.video_url) {
|
|
url = statusData.output.video_url
|
|
} else if (statusData.output?.url) {
|
|
url = statusData.output.url
|
|
}
|
|
|
|
if (url) {
|
|
console.log('✅ VideoGen: Generation complete, URL:', url)
|
|
setVideoUrl(url)
|
|
setIsGenerating(false)
|
|
|
|
this.editor.updateShape({
|
|
id: shape.id,
|
|
type: shape.type,
|
|
props: {
|
|
...shape.props,
|
|
videoUrl: url,
|
|
isLoading: false,
|
|
prompt: prompt
|
|
}
|
|
})
|
|
return
|
|
} else {
|
|
console.log('⚠️ VideoGen: Completed but no video URL in output:', statusData.output)
|
|
throw new Error('Video generation completed but no video URL returned')
|
|
}
|
|
} else if (statusData.status === 'FAILED') {
|
|
throw new Error(statusData.error || 'Video generation failed')
|
|
} else if (statusData.status === 'CANCELLED') {
|
|
throw new Error('Video generation was cancelled')
|
|
}
|
|
}
|
|
|
|
throw new Error('Video generation timed out after 4 minutes')
|
|
} catch (error: any) {
|
|
const errorMessage = error.message || 'Unknown error during video generation'
|
|
console.error('❌ VideoGen: Generation error:', errorMessage)
|
|
setError(errorMessage)
|
|
setIsGenerating(false)
|
|
|
|
this.editor.updateShape({
|
|
id: shape.id,
|
|
type: shape.type,
|
|
props: { ...shape.props, isLoading: false, error: errorMessage }
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
this.editor.deleteShape(shape.id)
|
|
}
|
|
|
|
const handleMinimize = () => {
|
|
setIsMinimized(!isMinimized)
|
|
}
|
|
|
|
const handleTagsChange = (newTags: string[]) => {
|
|
this.editor.updateShape({
|
|
id: shape.id,
|
|
type: shape.type,
|
|
props: { ...shape.props, tags: newTags }
|
|
})
|
|
}
|
|
|
|
return (
|
|
<HTMLContainer id={shape.id}>
|
|
<StandardizedToolWrapper
|
|
title="🎬 Video Generator (Wan2.1)"
|
|
primaryColor={VideoGenShape.PRIMARY_COLOR}
|
|
isSelected={isSelected}
|
|
width={shape.props.w}
|
|
height={shape.props.h}
|
|
onClose={handleClose}
|
|
onMinimize={handleMinimize}
|
|
isMinimized={isMinimized}
|
|
editor={this.editor}
|
|
shapeId={shape.id}
|
|
tags={shape.props.tags}
|
|
onTagsChange={handleTagsChange}
|
|
tagsEditable={true}
|
|
headerContent={
|
|
isGenerating ? (
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
🎬 Video Generator
|
|
<span style={{
|
|
marginLeft: 'auto',
|
|
fontSize: '11px',
|
|
color: VideoGenShape.PRIMARY_COLOR,
|
|
animation: 'pulse 1.5s ease-in-out infinite'
|
|
}}>
|
|
Generating...
|
|
</span>
|
|
</span>
|
|
) : undefined
|
|
}
|
|
>
|
|
<div style={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
padding: '16px',
|
|
gap: '12px',
|
|
overflow: 'auto',
|
|
backgroundColor: '#fafafa'
|
|
}}>
|
|
{!videoUrl && (
|
|
<>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<label style={{ color: '#555', fontSize: '12px', fontWeight: '600' }}>
|
|
Video Prompt
|
|
</label>
|
|
<textarea
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
placeholder="Describe the video you want to generate..."
|
|
disabled={isGenerating}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{
|
|
width: '100%',
|
|
minHeight: '80px',
|
|
padding: '10px',
|
|
backgroundColor: '#fff',
|
|
color: '#333',
|
|
border: '1px solid #ddd',
|
|
borderRadius: '6px',
|
|
fontSize: '13px',
|
|
fontFamily: 'inherit',
|
|
resize: 'vertical',
|
|
boxSizing: 'border-box'
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={{ color: '#555', fontSize: '11px', display: 'block', marginBottom: '4px', fontWeight: '500' }}>
|
|
Duration (seconds)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="10"
|
|
value={shape.props.duration}
|
|
onChange={(e) => {
|
|
this.editor.updateShape({
|
|
id: shape.id,
|
|
type: shape.type,
|
|
props: { ...shape.props, duration: parseInt(e.target.value) || 3 }
|
|
})
|
|
}}
|
|
disabled={isGenerating}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{
|
|
width: '100%',
|
|
padding: '8px',
|
|
backgroundColor: '#fff',
|
|
color: '#333',
|
|
border: '1px solid #ddd',
|
|
borderRadius: '6px',
|
|
fontSize: '13px',
|
|
boxSizing: 'border-box'
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleGenerate}
|
|
disabled={isGenerating || !prompt.trim()}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{
|
|
padding: '8px 20px',
|
|
backgroundColor: isGenerating ? '#ccc' : VideoGenShape.PRIMARY_COLOR,
|
|
color: '#fff',
|
|
border: 'none',
|
|
borderRadius: '6px',
|
|
fontSize: '13px',
|
|
fontWeight: '600',
|
|
cursor: isGenerating ? 'not-allowed' : 'pointer',
|
|
transition: 'all 0.2s',
|
|
whiteSpace: 'nowrap',
|
|
opacity: isGenerating || !prompt.trim() ? 0.6 : 1
|
|
}}
|
|
>
|
|
{isGenerating ? 'Generating...' : 'Generate Video'}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div style={{
|
|
padding: '12px',
|
|
backgroundColor: '#fee',
|
|
border: '1px solid #fcc',
|
|
color: '#c33',
|
|
borderRadius: '6px',
|
|
fontSize: '12px',
|
|
lineHeight: '1.4'
|
|
}}>
|
|
<strong>Error:</strong> {error}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{
|
|
marginTop: 'auto',
|
|
padding: '12px',
|
|
backgroundColor: '#f0f0f0',
|
|
borderRadius: '6px',
|
|
fontSize: '11px',
|
|
color: '#666',
|
|
lineHeight: '1.5'
|
|
}}>
|
|
<div><strong>Note:</strong> Video generation uses RunPod GPU</div>
|
|
<div>Cost: ~$0.50 per video | Processing: 30-90 seconds</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{videoUrl && (
|
|
<>
|
|
<video
|
|
src={videoUrl}
|
|
controls
|
|
autoPlay
|
|
loop
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{
|
|
width: '100%',
|
|
maxHeight: '280px',
|
|
borderRadius: '6px',
|
|
backgroundColor: '#000'
|
|
}}
|
|
/>
|
|
|
|
<div style={{
|
|
padding: '10px',
|
|
backgroundColor: '#f0f0f0',
|
|
borderRadius: '6px',
|
|
fontSize: '11px',
|
|
color: '#555',
|
|
wordBreak: 'break-word'
|
|
}}>
|
|
<strong>Prompt:</strong> {shape.props.prompt || prompt}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button
|
|
onClick={() => {
|
|
setVideoUrl(null)
|
|
setPrompt("")
|
|
this.editor.updateShape({
|
|
id: shape.id,
|
|
type: shape.type,
|
|
props: { ...shape.props, videoUrl: null, prompt: "" }
|
|
})
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{
|
|
flex: 1,
|
|
padding: '10px',
|
|
backgroundColor: '#e0e0e0',
|
|
color: '#333',
|
|
border: 'none',
|
|
borderRadius: '6px',
|
|
fontSize: '12px',
|
|
fontWeight: '500',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
New Video
|
|
</button>
|
|
|
|
<a
|
|
href={videoUrl}
|
|
download="generated-video.mp4"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{
|
|
flex: 1,
|
|
padding: '10px',
|
|
backgroundColor: VideoGenShape.PRIMARY_COLOR,
|
|
color: '#fff',
|
|
border: 'none',
|
|
borderRadius: '6px',
|
|
fontSize: '12px',
|
|
fontWeight: '600',
|
|
textAlign: 'center',
|
|
textDecoration: 'none',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
Download
|
|
</a>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<style>{`
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
`}</style>
|
|
</StandardizedToolWrapper>
|
|
</HTMLContainer>
|
|
)
|
|
}
|
|
|
|
indicator(shape: IVideoGen) {
|
|
return <rect width={shape.props.w} height={shape.props.h} rx={8} />
|
|
}
|
|
}
|