diff --git a/backend/routers/meta.py b/backend/routers/meta.py index 444e7fa..efb8109 100644 --- a/backend/routers/meta.py +++ b/backend/routers/meta.py @@ -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) + diff --git a/backend/services/meta_video_client.py b/backend/services/meta_video_client.py new file mode 100644 index 0000000..a3810cc --- /dev/null +++ b/backend/services/meta_video_client.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 95b2524..0da81ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/services/metaai-api b/services/metaai-api deleted file mode 160000 index 8f4ac67..0000000 --- a/services/metaai-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8f4ac67c01703e0c0e0c2b1cfd70a6d9b53fc9a8