v3.1.0: Integrate Meta AI video generation, remove redundant wrapper
- Add backend/services/meta_video_client.py (direct video generation) - Update routers/meta.py with working /meta/video endpoint - Delete services/metaai-api/ (~1500 lines removed) - Simplify docker-compose.yml to single container - No external service dependencies for Meta AI
This commit is contained in:
parent
2d301d6594
commit
4050f4c853
4 changed files with 469 additions and 17 deletions
|
|
@ -112,11 +112,39 @@ async def meta_video(request: MetaVideoRequest):
|
|||
- **cookies**: Meta AI cookies
|
||||
- **aspectRatio**: portrait, landscape, or square
|
||||
"""
|
||||
# Note: Meta AI video generation via GraphQL is complex
|
||||
# This is a placeholder - the full implementation would require
|
||||
# porting the entire meta/video/route.ts logic
|
||||
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Meta AI video generation not yet implemented in FastAPI backend"
|
||||
)
|
||||
if not request.cookies:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Meta AI cookies required for video generation"
|
||||
)
|
||||
|
||||
print(f"[Meta Video Route] Generating video for: \"{request.prompt[:30]}...\"")
|
||||
|
||||
try:
|
||||
from services.meta_video_client import MetaVideoClient
|
||||
|
||||
client = MetaVideoClient(cookies=request.cookies)
|
||||
results = await client.generate_video(
|
||||
prompt=request.prompt,
|
||||
wait_before_poll=10,
|
||||
max_attempts=30,
|
||||
poll_interval=5
|
||||
)
|
||||
|
||||
videos = [
|
||||
MetaVideoResult(
|
||||
url=r.url,
|
||||
prompt=r.prompt,
|
||||
model="meta-kadabra"
|
||||
) for r in results
|
||||
]
|
||||
|
||||
return MetaVideoResponse(success=True, videos=videos)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Meta Video Route] Error: {e}")
|
||||
error_message = str(e)
|
||||
if "expired" in error_message.lower() or "invalid" in error_message.lower():
|
||||
raise HTTPException(status_code=401, detail=error_message)
|
||||
raise HTTPException(status_code=500, detail=error_message)
|
||||
|
||||
|
|
|
|||
432
backend/services/meta_video_client.py
Normal file
432
backend/services/meta_video_client.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
"""
|
||||
Meta AI Video Generation Client for FastAPI
|
||||
|
||||
Ported from services/metaai-api/src/metaai_api/video_generation.py
|
||||
Integrated directly into the FastAPI backend to eliminate separate service.
|
||||
"""
|
||||
import httpx
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import re
|
||||
import asyncio
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
GRAPHQL_URL = "https://www.meta.ai/api/graphql/"
|
||||
META_AI_BASE = "https://www.meta.ai"
|
||||
|
||||
|
||||
class MetaVideoResult:
|
||||
def __init__(self, url: str, prompt: str, conversation_id: str):
|
||||
self.url = url
|
||||
self.prompt = prompt
|
||||
self.conversation_id = conversation_id
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"url": self.url,
|
||||
"prompt": self.prompt,
|
||||
"conversation_id": self.conversation_id
|
||||
}
|
||||
|
||||
|
||||
class MetaVideoClient:
|
||||
"""
|
||||
Async client for Meta AI video generation.
|
||||
Handles session tokens, video creation requests, and polling for results.
|
||||
"""
|
||||
|
||||
def __init__(self, cookies: str):
|
||||
"""
|
||||
Initialize the video client with cookies.
|
||||
|
||||
Args:
|
||||
cookies: Cookie string or JSON array of cookies
|
||||
"""
|
||||
self.cookies_str = self._normalize_cookies(cookies)
|
||||
self.cookies_dict = self._parse_cookies(self.cookies_str)
|
||||
self.lsd: Optional[str] = None
|
||||
self.fb_dtsg: Optional[str] = None
|
||||
|
||||
def _normalize_cookies(self, cookies: str) -> str:
|
||||
"""Normalize cookies from JSON array to string format"""
|
||||
if not cookies:
|
||||
return ""
|
||||
|
||||
try:
|
||||
trimmed = cookies.strip()
|
||||
if trimmed.startswith('['):
|
||||
parsed = json.loads(trimmed)
|
||||
if isinstance(parsed, list):
|
||||
return "; ".join(
|
||||
f"{c['name']}={c['value']}" for c in parsed
|
||||
if isinstance(c, dict) and 'name' in c and 'value' in c
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
return cookies
|
||||
|
||||
def _parse_cookies(self, cookie_str: str) -> Dict[str, str]:
|
||||
"""Parse cookie string into dictionary"""
|
||||
cookies = {}
|
||||
for item in cookie_str.split('; '):
|
||||
if '=' in item:
|
||||
key, value = item.split('=', 1)
|
||||
cookies[key] = value
|
||||
return cookies
|
||||
|
||||
async def init_session(self) -> None:
|
||||
"""Fetch lsd and fb_dtsg tokens from Meta AI page"""
|
||||
print("[Meta Video] Initializing session tokens...")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
META_AI_BASE,
|
||||
headers={
|
||||
"Cookie": self.cookies_str,
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
||||
}
|
||||
)
|
||||
html = response.text
|
||||
|
||||
# Extract LSD token
|
||||
lsd_match = re.search(r'"LSD",\[\],\{"token":"([^"]+)"', html)
|
||||
if lsd_match:
|
||||
self.lsd = lsd_match.group(1)
|
||||
else:
|
||||
# Fallback patterns
|
||||
lsd_match = re.search(r'"lsd":"([^"]+)"', html) or \
|
||||
re.search(r'name="lsd" value="([^"]+)"', html)
|
||||
if lsd_match:
|
||||
self.lsd = lsd_match.group(1)
|
||||
|
||||
# Extract FB DTSG token
|
||||
dtsg_match = re.search(r'DTSGInitData",\[\],\{"token":"([^"]+)"', html)
|
||||
if dtsg_match:
|
||||
self.fb_dtsg = dtsg_match.group(1)
|
||||
else:
|
||||
dtsg_match = re.search(r'"DTSGInitialData".*?"token":"([^"]+)"', html)
|
||||
if dtsg_match:
|
||||
self.fb_dtsg = dtsg_match.group(1)
|
||||
|
||||
if not self.lsd or not self.fb_dtsg:
|
||||
if 'login_form' in html or 'facebook.com/login' in html:
|
||||
raise Exception("Meta AI: Cookies expired or invalid - please refresh")
|
||||
raise Exception("Meta AI: Failed to extract session tokens")
|
||||
|
||||
print(f"[Meta Video] Got tokens - lsd: {self.lsd[:10]}..., dtsg: {self.fb_dtsg[:10]}...")
|
||||
|
||||
async def generate_video(
|
||||
self,
|
||||
prompt: str,
|
||||
wait_before_poll: int = 10,
|
||||
max_attempts: int = 30,
|
||||
poll_interval: int = 5
|
||||
) -> List[MetaVideoResult]:
|
||||
"""
|
||||
Generate video from text prompt.
|
||||
|
||||
Args:
|
||||
prompt: Text prompt for video generation
|
||||
wait_before_poll: Seconds to wait before polling
|
||||
max_attempts: Maximum polling attempts
|
||||
poll_interval: Seconds between polls
|
||||
|
||||
Returns:
|
||||
List of MetaVideoResult with video URLs
|
||||
"""
|
||||
# Initialize session if needed
|
||||
if not self.lsd or not self.fb_dtsg:
|
||||
await self.init_session()
|
||||
|
||||
# Step 1: Create video generation request
|
||||
print(f"[Meta Video] Generating video for: \"{prompt[:50]}...\"")
|
||||
conversation_id = await self._create_video_request(prompt)
|
||||
|
||||
if not conversation_id:
|
||||
raise Exception("Failed to create video generation request")
|
||||
|
||||
print(f"[Meta Video] Got conversation ID: {conversation_id}")
|
||||
|
||||
# Step 2: Wait before polling
|
||||
print(f"[Meta Video] Waiting {wait_before_poll}s before polling...")
|
||||
await asyncio.sleep(wait_before_poll)
|
||||
|
||||
# Step 3: Poll for video URLs
|
||||
video_urls = await self._poll_for_videos(
|
||||
conversation_id,
|
||||
max_attempts=max_attempts,
|
||||
poll_interval=poll_interval
|
||||
)
|
||||
|
||||
if not video_urls:
|
||||
raise Exception("No videos generated after polling")
|
||||
|
||||
return [
|
||||
MetaVideoResult(url=url, prompt=prompt, conversation_id=conversation_id)
|
||||
for url in video_urls
|
||||
]
|
||||
|
||||
async def _create_video_request(self, prompt: str) -> Optional[str]:
|
||||
"""Send video generation request to Meta AI"""
|
||||
external_conversation_id = str(uuid.uuid4())
|
||||
offline_threading_id = str(int(time.time() * 1000000000))[:19]
|
||||
thread_session_id = str(uuid.uuid4())
|
||||
bot_offline_threading_id = str(int(time.time() * 1000000000) + 1)[:19]
|
||||
qpl_join_id = str(uuid.uuid4()).replace('-', '')
|
||||
spin_t = str(int(time.time()))
|
||||
|
||||
# Build variables JSON
|
||||
variables = json.dumps({
|
||||
"message": {"sensitive_string_value": prompt},
|
||||
"externalConversationId": external_conversation_id,
|
||||
"offlineThreadingId": offline_threading_id,
|
||||
"threadSessionId": thread_session_id,
|
||||
"isNewConversation": True,
|
||||
"suggestedPromptIndex": None,
|
||||
"promptPrefix": None,
|
||||
"entrypoint": "KADABRA__CHAT__UNIFIED_INPUT_BAR",
|
||||
"attachments": [],
|
||||
"attachmentsV2": [],
|
||||
"activeMediaSets": [],
|
||||
"activeCardVersions": [],
|
||||
"activeArtifactVersion": None,
|
||||
"userUploadEditModeInput": None,
|
||||
"reelComposeInput": None,
|
||||
"qplJoinId": qpl_join_id,
|
||||
"sourceRemixPostId": None,
|
||||
"gkPlannerOrReasoningEnabled": True,
|
||||
"selectedModel": "BASIC_OPTION",
|
||||
"conversationMode": None,
|
||||
"selectedAgentType": "PLANNER",
|
||||
"conversationStarterId": None,
|
||||
"promptType": None,
|
||||
"artifactRewriteOptions": None,
|
||||
"imagineOperationRequest": None,
|
||||
"imagineClientOptions": {"orientation": "VERTICAL"},
|
||||
"spaceId": None,
|
||||
"sparkSnapshotId": None,
|
||||
"topicPageId": None,
|
||||
"includeSpace": False,
|
||||
"storybookId": None,
|
||||
"messagePersistentInput": {
|
||||
"attachment_size": None,
|
||||
"attachment_type": None,
|
||||
"bot_message_offline_threading_id": bot_offline_threading_id,
|
||||
"conversation_mode": None,
|
||||
"external_conversation_id": external_conversation_id,
|
||||
"is_new_conversation": True,
|
||||
"meta_ai_entry_point": "KADABRA__CHAT__UNIFIED_INPUT_BAR",
|
||||
"offline_threading_id": offline_threading_id,
|
||||
"prompt_id": None,
|
||||
"prompt_session_id": thread_session_id
|
||||
},
|
||||
"alakazam_enabled": True,
|
||||
"skipInFlightMessageWithParams": None,
|
||||
"__relay_internal__pv__KadabraSocialSearchEnabledrelayprovider": False,
|
||||
"__relay_internal__pv__KadabraZeitgeistEnabledrelayprovider": False,
|
||||
"__relay_internal__pv__alakazam_enabledrelayprovider": True,
|
||||
"__relay_internal__pv__AbraArtifactsEnabledrelayprovider": True,
|
||||
"__relay_internal__pv__AbraPlannerEnabledrelayprovider": True,
|
||||
"__relay_internal__pv__WebPixelRatiorelayprovider": 1,
|
||||
"__relay_internal__pv__KadabraVideoDeliveryRequestrelayprovider": {
|
||||
"dash_manifest_requests": [{}],
|
||||
"progressive_url_requests": [{"quality": "HD"}, {"quality": "SD"}]
|
||||
}
|
||||
}, separators=(',', ':'))
|
||||
|
||||
# Build multipart form body
|
||||
boundary = "----WebKitFormBoundaryu59CeaZS4ag939lz"
|
||||
body = self._build_multipart_body(boundary, variables, spin_t)
|
||||
|
||||
headers = {
|
||||
'accept': '*/*',
|
||||
'accept-language': 'en-US,en;q=0.5',
|
||||
'content-type': f'multipart/form-data; boundary={boundary}',
|
||||
'origin': META_AI_BASE,
|
||||
'referer': f'{META_AI_BASE}/',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'x-fb-lsd': self.lsd,
|
||||
}
|
||||
|
||||
url = f"{GRAPHQL_URL}?fb_dtsg={self.fb_dtsg}&jazoest=25499&lsd={self.lsd}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers=headers,
|
||||
cookies=self.cookies_dict,
|
||||
content=body.encode('utf-8')
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return external_conversation_id
|
||||
else:
|
||||
print(f"[Meta Video] Request failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Meta Video] Request error: {e}")
|
||||
return None
|
||||
|
||||
def _build_multipart_body(
|
||||
self,
|
||||
boundary: str,
|
||||
variables: str,
|
||||
spin_t: str
|
||||
) -> str:
|
||||
"""Build multipart form body for video request"""
|
||||
parts = [
|
||||
('av', '813590375178585'),
|
||||
('__user', '0'),
|
||||
('__a', '1'),
|
||||
('__req', 'q'),
|
||||
('__hs', '20413.HYP:kadabra_pkg.2.1...0'),
|
||||
('dpr', '1'),
|
||||
('__ccg', 'GOOD'),
|
||||
('__rev', '1030219547'),
|
||||
('__s', 'q59jx4:9bnqdw:3ats33'),
|
||||
('__hsi', '7575127759957881428'),
|
||||
('__dyn', '7xeUjG1mxu1syUqxemh0no6u5U4e2C1vzEdE98K360CEbo1nEhw2nVEtwMw6ywaq221FwpUO0n24oaEnxO0Bo7O2l0Fwqo31w9O1lwlE-U2zxe2GewbS361qw82dUlwhE-15wmo423-0j52oS0Io5d0bS1LBwNwKG0WE8oC1IwGw-wlUcE2-G2O7E5y1rwa211wo84y1iwfe1aw'),
|
||||
('__csr', ''),
|
||||
('__comet_req', '72'),
|
||||
('fb_dtsg', self.fb_dtsg),
|
||||
('jazoest', '25499'),
|
||||
('lsd', self.lsd),
|
||||
('__spin_r', '1030219547'),
|
||||
('__spin_b', 'trunk'),
|
||||
('__spin_t', spin_t),
|
||||
('__jssesw', '1'),
|
||||
('__crn', 'comet.kadabra.KadabraAssistantRoute'),
|
||||
('fb_api_caller_class', 'RelayModern'),
|
||||
('fb_api_req_friendly_name', 'useKadabraSendMessageMutation'),
|
||||
('server_timestamps', 'true'),
|
||||
('variables', variables),
|
||||
('doc_id', '25290947477183545'),
|
||||
]
|
||||
|
||||
body_lines = []
|
||||
for name, value in parts:
|
||||
body_lines.append(f'------{boundary[4:]}')
|
||||
body_lines.append(f'Content-Disposition: form-data; name="{name}"')
|
||||
body_lines.append('')
|
||||
body_lines.append(value)
|
||||
|
||||
body_lines.append(f'------{boundary[4:]}--')
|
||||
return '\r\n'.join(body_lines) + '\r\n'
|
||||
|
||||
async def _poll_for_videos(
|
||||
self,
|
||||
conversation_id: str,
|
||||
max_attempts: int = 30,
|
||||
poll_interval: int = 5
|
||||
) -> List[str]:
|
||||
"""Poll for video URLs from a conversation"""
|
||||
print(f"[Meta Video] Polling for videos (max {max_attempts} attempts)...")
|
||||
|
||||
variables = {
|
||||
"prompt_id": conversation_id,
|
||||
"__relay_internal__pv__AbraIsLoggedOutrelayprovider": False,
|
||||
"__relay_internal__pv__alakazam_enabledrelayprovider": True,
|
||||
"__relay_internal__pv__AbraArtifactsEnabledrelayprovider": True,
|
||||
"__relay_internal__pv__AbraPlannerEnabledrelayprovider": True,
|
||||
"__relay_internal__pv__WebPixelRatiorelayprovider": 1,
|
||||
"__relay_internal__pv__KadabraVideoDeliveryRequestrelayprovider": {
|
||||
"dash_manifest_requests": [{}],
|
||||
"progressive_url_requests": [{"quality": "HD"}, {"quality": "SD"}]
|
||||
}
|
||||
}
|
||||
|
||||
data = {
|
||||
'av': '813590375178585',
|
||||
'__user': '0',
|
||||
'__a': '1',
|
||||
'__req': 's',
|
||||
'__hs': '20413.HYP:kadabra_pkg.2.1...0',
|
||||
'dpr': '1',
|
||||
'__ccg': 'GOOD',
|
||||
'__rev': '1030219547',
|
||||
'__comet_req': '72',
|
||||
'fb_dtsg': self.fb_dtsg,
|
||||
'jazoest': '25499',
|
||||
'lsd': self.lsd,
|
||||
'__spin_r': '1030219547',
|
||||
'__spin_b': 'trunk',
|
||||
'__spin_t': str(int(time.time())),
|
||||
'__jssesw': '1',
|
||||
'__crn': 'comet.kadabra.KadabraAssistantRoute',
|
||||
'fb_api_caller_class': 'RelayModern',
|
||||
'fb_api_req_friendly_name': 'KadabraPromptRootQuery',
|
||||
'server_timestamps': 'true',
|
||||
'variables': json.dumps(variables),
|
||||
'doc_id': '25290569913909283',
|
||||
}
|
||||
|
||||
headers = {
|
||||
'accept': '*/*',
|
||||
'accept-language': 'en-US,en;q=0.5',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'origin': META_AI_BASE,
|
||||
'referer': f'{META_AI_BASE}/',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'x-fb-lsd': self.lsd,
|
||||
'x-fb-friendly-name': 'KadabraPromptRootQuery'
|
||||
}
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
GRAPHQL_URL,
|
||||
headers=headers,
|
||||
cookies=self.cookies_dict,
|
||||
data=data
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
video_urls = self._extract_video_urls(response.text)
|
||||
if video_urls:
|
||||
print(f"[Meta Video] Found {len(video_urls)} video(s) on attempt {attempt}")
|
||||
return video_urls
|
||||
else:
|
||||
print(f"[Meta Video] Attempt {attempt}/{max_attempts} - no videos yet")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Meta Video] Poll error: {e}")
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
return []
|
||||
|
||||
def _extract_video_urls(self, response_text: str) -> List[str]:
|
||||
"""Extract video URLs from Meta AI response"""
|
||||
video_urls = set()
|
||||
|
||||
try:
|
||||
data = json.loads(response_text)
|
||||
|
||||
def search_for_urls(obj):
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if key in ['video_url', 'progressive_url', 'generated_video_uri', 'uri', 'url']:
|
||||
if isinstance(value, str) and 'fbcdn' in value and '.mp4' in value:
|
||||
video_urls.add(value)
|
||||
search_for_urls(value)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
search_for_urls(item)
|
||||
elif isinstance(obj, str):
|
||||
if 'fbcdn' in obj and ('.mp4' in obj or 'video' in obj.lower()):
|
||||
urls = re.findall(r'https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*', obj)
|
||||
video_urls.update(urls)
|
||||
|
||||
search_for_urls(data)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Fallback regex extraction
|
||||
urls = re.findall(r'https?://[^\s"\'<>]+fbcdn[^\s"\'<>]+\.mp4[^\s"\'<>]*', response_text)
|
||||
video_urls.update(urls)
|
||||
|
||||
return list(video_urls)
|
||||
|
|
@ -9,11 +9,4 @@ services:
|
|||
environment:
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- ./data:/app/data # Persist prompt library
|
||||
# Optional: Meta AI Free Wrapper (if needed)
|
||||
# metaai-free-api:
|
||||
# build: ./services/metaai-api
|
||||
# container_name: metaai-free-api
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8001:8000"
|
||||
- ./data:/app/data # Persist prompt library and history
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 8f4ac67c01703e0c0e0c2b1cfd70a6d9b53fc9a8
|
||||
Loading…
Reference in a new issue