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 { 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(shape.props.error) const [videoUrl, setVideoUrl] = useState(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 ( 🎬 Video Generator Generating... ) : undefined } >
{!videoUrl && ( <>