feat: add Meta AI video generation
- Add /video/generate endpoint to crawl4ai Python service - Add VideoGenerateRequest and VideoGenerateResponse models - Add generateVideo method to MetaCrawlClient TypeScript client - Add /api/meta/video Next.js API route - Add 'Video' button in PromptHero UI (visible only for Meta AI provider) - Blue/cyan gradient styling for Video button to differentiate from Generate
This commit is contained in:
parent
2a4bf8b58b
commit
0f87b8ef99
7 changed files with 345 additions and 4 deletions
|
|
@ -7,3 +7,4 @@ README.md
|
||||||
.git
|
.git
|
||||||
.env*
|
.env*
|
||||||
! .env.example
|
! .env.example
|
||||||
|
.venv
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -45,3 +45,4 @@ next-env.d.ts
|
||||||
.venv
|
.venv
|
||||||
error.log
|
error.log
|
||||||
__pycache__
|
__pycache__
|
||||||
|
*.log
|
||||||
|
|
|
||||||
69
app/api/meta/video/route.ts
Normal file
69
app/api/meta/video/route.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { MetaCrawlClient } from '@/lib/providers/meta-crawl-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/meta/video
|
||||||
|
*
|
||||||
|
* Generate a video from a text prompt using Meta AI.
|
||||||
|
* Video generation takes 30-60+ seconds, so this endpoint may take a while.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { prompt, cookies: clientCookies } = await req.json();
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cookies from request body or cookie header
|
||||||
|
let cookieString = clientCookies || req.cookies.get('meta_cookies')?.value;
|
||||||
|
|
||||||
|
if (!cookieString) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Meta AI cookies not found. Please configure settings." },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Meta Video API] Starting video generation for prompt: "${prompt.substring(0, 50)}..."`);
|
||||||
|
|
||||||
|
const client = new MetaCrawlClient();
|
||||||
|
|
||||||
|
// Check if crawl4ai service is available
|
||||||
|
const isHealthy = await client.healthCheck();
|
||||||
|
if (!isHealthy) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Meta AI video service is not available. Make sure crawl4ai is running." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate video - this can take 30-60+ seconds
|
||||||
|
const result = await client.generateVideo(prompt, cookieString);
|
||||||
|
|
||||||
|
if (!result.success || result.videos.length === 0) {
|
||||||
|
throw new Error(result.error || "No videos generated");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Meta Video API] Successfully generated ${result.videos.length} video(s)`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
videos: result.videos,
|
||||||
|
conversation_id: result.conversation_id
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error("[Meta Video API] Error:", err.message);
|
||||||
|
|
||||||
|
const msg = err.message || "";
|
||||||
|
const isAuthError = msg.includes("401") || msg.includes("403") ||
|
||||||
|
msg.includes("auth") || msg.includes("cookies") || msg.includes("expired");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || "Video generation failed" },
|
||||||
|
{ status: isAuthError ? 401 : 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useRef, useState, useEffect } from "react";
|
import React, { useRef, useState, useEffect } from "react";
|
||||||
import { useStore, ReferenceCategory } from "@/lib/store";
|
import { useStore, ReferenceCategory } from "@/lib/store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Sparkles, Maximize2, X, Hash, AlertTriangle, Upload, Zap, Brain, Settings, Settings2 } from "lucide-react";
|
import { Sparkles, Maximize2, X, Hash, AlertTriangle, Upload, Zap, Brain, Settings, Settings2, Video } from "lucide-react";
|
||||||
|
|
||||||
const IMAGE_COUNTS = [1, 2, 4];
|
const IMAGE_COUNTS = [1, 2, 4];
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ export function PromptHero() {
|
||||||
} = useStore();
|
} = useStore();
|
||||||
|
|
||||||
const [isGenerating, setLocalIsGenerating] = useState(false);
|
const [isGenerating, setLocalIsGenerating] = useState(false);
|
||||||
|
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
|
||||||
const [uploadingRefs, setUploadingRefs] = useState<Record<string, boolean>>({});
|
const [uploadingRefs, setUploadingRefs] = useState<Record<string, boolean>>({});
|
||||||
const [errorNotification, setErrorNotification] = useState<{ message: string; type: 'error' | 'warning' } | null>(null);
|
const [errorNotification, setErrorNotification] = useState<{ message: string; type: 'error' | 'warning' } | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
@ -204,6 +205,76 @@ export function PromptHero() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle video generation (Meta AI only)
|
||||||
|
const handleGenerateVideo = async () => {
|
||||||
|
let finalPrompt = prompt.trim();
|
||||||
|
if (!finalPrompt || isGeneratingVideo || settings.provider !== 'meta') return;
|
||||||
|
|
||||||
|
setIsGeneratingVideo(true);
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[PromptHero] Starting Meta AI video generation...');
|
||||||
|
|
||||||
|
const res = await fetch('/api/meta/video', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: finalPrompt,
|
||||||
|
cookies: settings.metaCookies
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await res.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("API Error (Non-JSON response):", responseText.substring(0, 500));
|
||||||
|
throw new Error(`Server Error: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
|
if (data.videos && data.videos.length > 0) {
|
||||||
|
// Add videos to store
|
||||||
|
for (const video of data.videos) {
|
||||||
|
useStore.getState().addVideo({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
url: video.url,
|
||||||
|
prompt: video.prompt || finalPrompt,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Show success notification
|
||||||
|
setErrorNotification({
|
||||||
|
message: `🎬 Success! Generated ${data.videos.length} video(s). Check the Videos tab.`,
|
||||||
|
type: 'warning' // Using warning for visibility (amber color)
|
||||||
|
});
|
||||||
|
setTimeout(() => setErrorNotification(null), 5000);
|
||||||
|
} else {
|
||||||
|
throw new Error('No videos were generated');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[Video Gen]', e);
|
||||||
|
const errorMessage = e.message || '';
|
||||||
|
|
||||||
|
if (errorMessage.includes('401') || errorMessage.includes('cookies')) {
|
||||||
|
setShowCookieExpired(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorNotification({
|
||||||
|
message: `🎬 Video Error: ${errorMessage}`,
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
setTimeout(() => setErrorNotification(null), 8000);
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingVideo(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -626,7 +697,7 @@ export function PromptHero() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative z-10 flex items-center gap-1.5">
|
<div className="relative z-10 flex items-center gap-1.5">
|
||||||
{isGenerating ? (
|
{isGenerating && !isGeneratingVideo ? (
|
||||||
<>
|
<>
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
<span className="animate-pulse">Dreaming...</span>
|
<span className="animate-pulse">Dreaming...</span>
|
||||||
|
|
@ -643,6 +714,35 @@ export function PromptHero() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Generate Video Button - Only for Meta AI */}
|
||||||
|
{settings.provider === 'meta' && (
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateVideo}
|
||||||
|
disabled={isGenerating || !prompt.trim()}
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden px-4 py-1.5 rounded-lg font-bold text-sm text-white shadow-lg transition-all active:scale-95 group border border-white/10",
|
||||||
|
isGenerating
|
||||||
|
? "bg-gray-700 cursor-not-allowed"
|
||||||
|
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 hover:shadow-cyan-500/25"
|
||||||
|
)}
|
||||||
|
title="Generate video from prompt (30-60+ seconds)"
|
||||||
|
>
|
||||||
|
<div className="relative z-10 flex items-center gap-1.5">
|
||||||
|
{isGeneratingVideo ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
<span className="animate-pulse">Creating...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Video className="h-3 w-3 group-hover:scale-110 transition-transform" />
|
||||||
|
<span>Video</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,4 +171,52 @@ export class MetaCrawlClient {
|
||||||
const response = await fetch(`${this.baseUrl}/rate-limit`);
|
const response = await fetch(`${this.baseUrl}/rate-limit`);
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate video from text prompt using Meta AI
|
||||||
|
* Video generation takes longer than image generation (30-60+ seconds)
|
||||||
|
*/
|
||||||
|
async generateVideo(
|
||||||
|
prompt: string,
|
||||||
|
cookies: string
|
||||||
|
): Promise<MetaCrawlVideoResponse> {
|
||||||
|
console.log(`[MetaCrawl] Sending video request to ${this.baseUrl}/video/generate`);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/video/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
cookies
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`MetaCrawl video service error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: MetaCrawlVideoResponse = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Video generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaCrawlVideo {
|
||||||
|
url: string;
|
||||||
|
prompt: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaCrawlVideoResponse {
|
||||||
|
success: boolean;
|
||||||
|
videos: MetaCrawlVideo[];
|
||||||
|
error?: string;
|
||||||
|
conversation_id?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@ from .models import (
|
||||||
TaskStatusResponse,
|
TaskStatusResponse,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
GrokChatRequest,
|
GrokChatRequest,
|
||||||
GrokChatResponse
|
GrokChatResponse,
|
||||||
|
VideoGenerateRequest,
|
||||||
|
VideoGenerateResponse,
|
||||||
|
VideoResult
|
||||||
)
|
)
|
||||||
from .grok_client import GrokChatClient
|
from .grok_client import GrokChatClient
|
||||||
from .meta_crawler import meta_crawler
|
from .meta_crawler import meta_crawler
|
||||||
|
|
@ -171,7 +174,6 @@ async def delete_task(task_id: str):
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/grok/chat", response_model=GrokChatResponse)
|
@app.post("/grok/chat", response_model=GrokChatResponse)
|
||||||
async def grok_chat(request: GrokChatRequest):
|
async def grok_chat(request: GrokChatRequest):
|
||||||
"""
|
"""
|
||||||
|
|
@ -182,3 +184,102 @@ async def grok_chat(request: GrokChatRequest):
|
||||||
return GrokChatResponse(response=response)
|
return GrokChatResponse(response=response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/video/generate", response_model=VideoGenerateResponse)
|
||||||
|
async def generate_video(request: VideoGenerateRequest):
|
||||||
|
"""
|
||||||
|
Generate a video from a text prompt using Meta AI.
|
||||||
|
|
||||||
|
This uses the metaai_api library's video generation feature.
|
||||||
|
Video generation takes longer than image generation (30-60+ seconds).
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- prompt: The video generation prompt
|
||||||
|
- cookies: Facebook/Meta cookies (JSON array or string format)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse cookies to dict format for MetaAI
|
||||||
|
cookies_str = request.cookies.strip()
|
||||||
|
cookies_dict = {}
|
||||||
|
|
||||||
|
if cookies_str.startswith('['):
|
||||||
|
# JSON array format from Cookie Editor
|
||||||
|
parsed = json.loads(cookies_str)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
cookies_dict = {c['name']: c['value'] for c in parsed if 'name' in c and 'value' in c}
|
||||||
|
else:
|
||||||
|
# String format: "name1=value1; name2=value2"
|
||||||
|
for pair in cookies_str.split(';'):
|
||||||
|
pair = pair.strip()
|
||||||
|
if '=' in pair:
|
||||||
|
name, value = pair.split('=', 1)
|
||||||
|
cookies_dict[name.strip()] = value.strip()
|
||||||
|
|
||||||
|
if not cookies_dict:
|
||||||
|
return VideoGenerateResponse(
|
||||||
|
success=False,
|
||||||
|
videos=[],
|
||||||
|
error="No valid cookies provided"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[VideoGen] Starting video generation for: '{request.prompt[:50]}...'")
|
||||||
|
|
||||||
|
# Import MetaAI and run video generation in thread pool (it's synchronous)
|
||||||
|
from metaai_api import MetaAI
|
||||||
|
|
||||||
|
def run_video_gen():
|
||||||
|
ai = MetaAI(cookies=cookies_dict)
|
||||||
|
return ai.generate_video(
|
||||||
|
prompt=request.prompt,
|
||||||
|
wait_before_poll=10,
|
||||||
|
max_attempts=60, # Up to 5 minutes of polling
|
||||||
|
wait_seconds=5,
|
||||||
|
verbose=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run in thread pool since metaai_api is synchronous
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||||
|
result = await loop.run_in_executor(executor, run_video_gen)
|
||||||
|
|
||||||
|
if not result.get('success', False):
|
||||||
|
error_msg = result.get('error', 'Video generation failed')
|
||||||
|
print(f"[VideoGen] Failed: {error_msg}")
|
||||||
|
return VideoGenerateResponse(
|
||||||
|
success=False,
|
||||||
|
videos=[],
|
||||||
|
error=error_msg
|
||||||
|
)
|
||||||
|
|
||||||
|
video_urls = result.get('video_urls', [])
|
||||||
|
print(f"[VideoGen] Success! Got {len(video_urls)} video(s)")
|
||||||
|
|
||||||
|
videos = [
|
||||||
|
VideoResult(
|
||||||
|
url=url,
|
||||||
|
prompt=request.prompt,
|
||||||
|
model="meta_video"
|
||||||
|
)
|
||||||
|
for url in video_urls
|
||||||
|
]
|
||||||
|
|
||||||
|
return VideoGenerateResponse(
|
||||||
|
success=True,
|
||||||
|
videos=videos,
|
||||||
|
conversation_id=result.get('conversation_id')
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f"[VideoGen] Error: {str(e)}")
|
||||||
|
return VideoGenerateResponse(
|
||||||
|
success=False,
|
||||||
|
videos=[],
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -64,3 +64,24 @@ class HealthResponse(BaseModel):
|
||||||
status: str = "healthy"
|
status: str = "healthy"
|
||||||
version: str = "1.0.0"
|
version: str = "1.0.0"
|
||||||
browser_ready: bool = True
|
browser_ready: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class VideoGenerateRequest(BaseModel):
|
||||||
|
"""Request model for video generation"""
|
||||||
|
prompt: str = Field(..., description="Video generation prompt", min_length=1)
|
||||||
|
cookies: str = Field(..., description="Meta AI session cookies")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoResult(BaseModel):
|
||||||
|
"""Single generated video result"""
|
||||||
|
url: str
|
||||||
|
prompt: str
|
||||||
|
model: str = "meta_video"
|
||||||
|
|
||||||
|
|
||||||
|
class VideoGenerateResponse(BaseModel):
|
||||||
|
"""Response model for video generation"""
|
||||||
|
success: bool
|
||||||
|
videos: list[VideoResult] = []
|
||||||
|
error: Optional[str] = None
|
||||||
|
conversation_id: Optional[str] = None
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue