feat: add direct RunPod integration for video generation
- Add RunPod config helpers for image, video, text, whisper endpoints - Update VideoGenShapeUtil to call RunPod video endpoint directly - Add Ollama URL config for local LLM support - Remove dependency on AI orchestrator backend (not yet built) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
05197f8430
commit
083095c821
|
|
@ -16,6 +16,11 @@ export interface ClientConfig {
|
||||||
openaiApiKey?: string
|
openaiApiKey?: string
|
||||||
runpodApiKey?: string
|
runpodApiKey?: string
|
||||||
runpodEndpointId?: string
|
runpodEndpointId?: string
|
||||||
|
runpodImageEndpointId?: string
|
||||||
|
runpodVideoEndpointId?: string
|
||||||
|
runpodTextEndpointId?: string
|
||||||
|
runpodWhisperEndpointId?: string
|
||||||
|
ollamaUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -41,7 +46,12 @@ export function getClientConfig(): ClientConfig {
|
||||||
webhookSecret: import.meta.env.VITE_QUARTZ_WEBHOOK_SECRET || import.meta.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET,
|
webhookSecret: import.meta.env.VITE_QUARTZ_WEBHOOK_SECRET || import.meta.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET,
|
||||||
openaiApiKey: import.meta.env.VITE_OPENAI_API_KEY || import.meta.env.NEXT_PUBLIC_OPENAI_API_KEY,
|
openaiApiKey: import.meta.env.VITE_OPENAI_API_KEY || import.meta.env.NEXT_PUBLIC_OPENAI_API_KEY,
|
||||||
runpodApiKey: import.meta.env.VITE_RUNPOD_API_KEY || import.meta.env.NEXT_PUBLIC_RUNPOD_API_KEY,
|
runpodApiKey: import.meta.env.VITE_RUNPOD_API_KEY || import.meta.env.NEXT_PUBLIC_RUNPOD_API_KEY,
|
||||||
runpodEndpointId: import.meta.env.VITE_RUNPOD_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_ENDPOINT_ID,
|
runpodEndpointId: import.meta.env.VITE_RUNPOD_ENDPOINT_ID || import.meta.env.VITE_RUNPOD_IMAGE_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_ENDPOINT_ID,
|
||||||
|
runpodImageEndpointId: import.meta.env.VITE_RUNPOD_IMAGE_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_IMAGE_ENDPOINT_ID,
|
||||||
|
runpodVideoEndpointId: import.meta.env.VITE_RUNPOD_VIDEO_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_VIDEO_ENDPOINT_ID,
|
||||||
|
runpodTextEndpointId: import.meta.env.VITE_RUNPOD_TEXT_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_TEXT_ENDPOINT_ID,
|
||||||
|
runpodWhisperEndpointId: import.meta.env.VITE_RUNPOD_WHISPER_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_WHISPER_ENDPOINT_ID,
|
||||||
|
ollamaUrl: import.meta.env.VITE_OLLAMA_URL || import.meta.env.NEXT_PUBLIC_OLLAMA_URL,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Next.js environment
|
// Next.js environment
|
||||||
|
|
@ -73,13 +83,18 @@ export function getClientConfig(): ClientConfig {
|
||||||
webhookUrl: process.env.VITE_QUARTZ_WEBHOOK_URL || process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL,
|
webhookUrl: process.env.VITE_QUARTZ_WEBHOOK_URL || process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL,
|
||||||
webhookSecret: process.env.VITE_QUARTZ_WEBHOOK_SECRET || process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET,
|
webhookSecret: process.env.VITE_QUARTZ_WEBHOOK_SECRET || process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET,
|
||||||
runpodApiKey: process.env.VITE_RUNPOD_API_KEY || process.env.NEXT_PUBLIC_RUNPOD_API_KEY,
|
runpodApiKey: process.env.VITE_RUNPOD_API_KEY || process.env.NEXT_PUBLIC_RUNPOD_API_KEY,
|
||||||
runpodEndpointId: process.env.VITE_RUNPOD_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_ENDPOINT_ID,
|
runpodEndpointId: process.env.VITE_RUNPOD_ENDPOINT_ID || process.env.VITE_RUNPOD_IMAGE_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_ENDPOINT_ID,
|
||||||
|
runpodImageEndpointId: process.env.VITE_RUNPOD_IMAGE_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_IMAGE_ENDPOINT_ID,
|
||||||
|
runpodVideoEndpointId: process.env.VITE_RUNPOD_VIDEO_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_VIDEO_ENDPOINT_ID,
|
||||||
|
runpodTextEndpointId: process.env.VITE_RUNPOD_TEXT_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_TEXT_ENDPOINT_ID,
|
||||||
|
runpodWhisperEndpointId: process.env.VITE_RUNPOD_WHISPER_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_WHISPER_ENDPOINT_ID,
|
||||||
|
ollamaUrl: process.env.VITE_OLLAMA_URL || process.env.NEXT_PUBLIC_OLLAMA_URL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get RunPod configuration for API calls
|
* Get RunPod configuration for API calls (defaults to image endpoint)
|
||||||
*/
|
*/
|
||||||
export function getRunPodConfig(): { apiKey: string; endpointId: string } | null {
|
export function getRunPodConfig(): { apiKey: string; endpointId: string } | null {
|
||||||
const config = getClientConfig()
|
const config = getClientConfig()
|
||||||
|
|
@ -94,6 +109,86 @@ export function getRunPodConfig(): { apiKey: string; endpointId: string } | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get RunPod configuration for image generation
|
||||||
|
*/
|
||||||
|
export function getRunPodImageConfig(): { apiKey: string; endpointId: string } | null {
|
||||||
|
const config = getClientConfig()
|
||||||
|
const endpointId = config.runpodImageEndpointId || config.runpodEndpointId
|
||||||
|
|
||||||
|
if (!config.runpodApiKey || !endpointId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: config.runpodApiKey,
|
||||||
|
endpointId: endpointId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get RunPod configuration for video generation
|
||||||
|
*/
|
||||||
|
export function getRunPodVideoConfig(): { apiKey: string; endpointId: string } | null {
|
||||||
|
const config = getClientConfig()
|
||||||
|
|
||||||
|
if (!config.runpodApiKey || !config.runpodVideoEndpointId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: config.runpodApiKey,
|
||||||
|
endpointId: config.runpodVideoEndpointId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get RunPod configuration for text generation (vLLM)
|
||||||
|
*/
|
||||||
|
export function getRunPodTextConfig(): { apiKey: string; endpointId: string } | null {
|
||||||
|
const config = getClientConfig()
|
||||||
|
|
||||||
|
if (!config.runpodApiKey || !config.runpodTextEndpointId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: config.runpodApiKey,
|
||||||
|
endpointId: config.runpodTextEndpointId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get RunPod configuration for Whisper transcription
|
||||||
|
*/
|
||||||
|
export function getRunPodWhisperConfig(): { apiKey: string; endpointId: string } | null {
|
||||||
|
const config = getClientConfig()
|
||||||
|
|
||||||
|
if (!config.runpodApiKey || !config.runpodWhisperEndpointId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: config.runpodApiKey,
|
||||||
|
endpointId: config.runpodWhisperEndpointId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Ollama configuration for local LLM
|
||||||
|
*/
|
||||||
|
export function getOllamaConfig(): { url: string } | null {
|
||||||
|
const config = getClientConfig()
|
||||||
|
|
||||||
|
if (!config.ollamaUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: config.ollamaUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if RunPod integration is configured
|
* Check if RunPod integration is configured
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,21 @@ import {
|
||||||
TLBaseShape,
|
TLBaseShape,
|
||||||
} from "tldraw"
|
} from "tldraw"
|
||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
import { aiOrchestrator, isAIOrchestratorAvailable } from "@/lib/aiOrchestrator"
|
import { getRunPodVideoConfig } from "@/lib/clientConfig"
|
||||||
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
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<
|
type IVideoGen = TLBaseShape<
|
||||||
"VideoGen",
|
"VideoGen",
|
||||||
{
|
{
|
||||||
|
|
@ -66,6 +78,13 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
return
|
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)
|
console.log('🎬 VideoGen: Starting generation with prompt:', prompt)
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -78,28 +97,76 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if AI Orchestrator is available
|
const { apiKey, endpointId } = runpodConfig
|
||||||
const orchestratorAvailable = await isAIOrchestratorAvailable()
|
|
||||||
|
|
||||||
if (orchestratorAvailable) {
|
// Submit job to RunPod
|
||||||
console.log('🎬 VideoGen: Using AI Orchestrator for video generation')
|
console.log('🎬 VideoGen: Submitting to RunPod endpoint:', endpointId)
|
||||||
|
const runUrl = `https://api.runpod.ai/v2/${endpointId}/run`
|
||||||
|
|
||||||
// Use AI Orchestrator (always routes to RunPod for video)
|
const response = await fetch(runUrl, {
|
||||||
const job = await aiOrchestrator.generateVideo(prompt, {
|
method: 'POST',
|
||||||
model: shape.props.model,
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
input: {
|
||||||
|
prompt: prompt,
|
||||||
duration: shape.props.duration,
|
duration: shape.props.duration,
|
||||||
wait: true // Wait for completion
|
model: shape.props.model
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (job.status === 'completed' && job.result?.video_url) {
|
if (!response.ok) {
|
||||||
const url = job.result.video_url
|
const errorText = await response.text()
|
||||||
console.log('✅ VideoGen: Generation complete, URL:', url)
|
throw new Error(`RunPod API error: ${response.status} - ${errorText}`)
|
||||||
console.log(`💰 VideoGen: Cost: $${job.cost?.toFixed(4) || '0.00'}`)
|
}
|
||||||
|
|
||||||
|
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)
|
setVideoUrl(url)
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
|
|
||||||
// Update shape with video URL
|
|
||||||
this.editor.updateShape({
|
this.editor.updateShape({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
|
|
@ -110,21 +177,25 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
prompt: prompt
|
prompt: prompt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Video generation job did not return a video URL')
|
console.log('⚠️ VideoGen: Completed but no video URL in output:', statusData.output)
|
||||||
|
throw new Error('Video generation completed but no video URL returned')
|
||||||
}
|
}
|
||||||
} else {
|
} else if (statusData.status === 'FAILED') {
|
||||||
throw new Error(
|
throw new Error(statusData.error || 'Video generation failed')
|
||||||
'AI Orchestrator not available. Please configure VITE_AI_ORCHESTRATOR_URL or set up the orchestrator on your Netcup RS 8000 server.'
|
} 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) {
|
} catch (error: any) {
|
||||||
const errorMessage = error.message || 'Unknown error during video generation'
|
const errorMessage = error.message || 'Unknown error during video generation'
|
||||||
console.error('❌ VideoGen: Generation error:', errorMessage)
|
console.error('❌ VideoGen: Generation error:', errorMessage)
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
|
|
||||||
// Update shape with error
|
|
||||||
this.editor.updateShape({
|
this.editor.updateShape({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue