| | """Flow API Client for VideoFX (Veo)""" |
| | import asyncio |
| | import time |
| | import uuid |
| | import random |
| | import base64 |
| | from typing import Dict, Any, Optional, List |
| | from curl_cffi.requests import AsyncSession |
| | from ..core.logger import debug_logger |
| | from ..core.config import config |
| |
|
| |
|
| | class FlowClient: |
| | """VideoFX API客户端""" |
| |
|
| | def __init__(self, proxy_manager, db=None): |
| | self.proxy_manager = proxy_manager |
| | self.db = db |
| | self.labs_base_url = config.flow_labs_base_url |
| | self.api_base_url = config.flow_api_base_url |
| | self.timeout = config.flow_timeout |
| | |
| | self._user_agent_cache = {} |
| |
|
| | |
| | |
| | self._default_client_headers = { |
| | "sec-ch-ua-mobile": "?1", |
| | "sec-ch-ua-platform": "\"Android\"", |
| | "sec-fetch-dest": "empty", |
| | "sec-fetch-mode": "cors", |
| | "sec-fetch-site": "cross-site", |
| | "x-browser-channel": "stable", |
| | "x-browser-copyright": "Copyright 2026 Google LLC. All Rights reserved.", |
| | "x-browser-validation": "UujAs0GAwdnCJ9nvrswZ+O+oco0=", |
| | "x-browser-year": "2026", |
| | "x-client-data": "CJS2yQEIpLbJAQipncoBCNj9ygEIlKHLAQiFoM0BGP6lzwE=" |
| | } |
| |
|
| | def _generate_user_agent(self, account_id: str = None) -> str: |
| | """基于账号ID生成固定的 User-Agent |
| | |
| | Args: |
| | account_id: 账号标识(如 email 或 token_id),相同账号返回相同 UA |
| | |
| | Returns: |
| | User-Agent 字符串 |
| | """ |
| | |
| | if not account_id: |
| | account_id = f"random_{random.randint(1, 999999)}" |
| | |
| | |
| | if account_id in self._user_agent_cache: |
| | return self._user_agent_cache[account_id] |
| | |
| | |
| | import hashlib |
| | seed = int(hashlib.md5(account_id.encode()).hexdigest()[:8], 16) |
| | rng = random.Random(seed) |
| | |
| | |
| | chrome_versions = ["130.0.0.0", "131.0.0.0", "132.0.0.0", "129.0.0.0"] |
| | |
| | firefox_versions = ["133.0", "132.0", "131.0", "134.0"] |
| | |
| | safari_versions = ["18.2", "18.1", "18.0", "17.6"] |
| | |
| | edge_versions = ["130.0.0.0", "131.0.0.0", "132.0.0.0"] |
| |
|
| | |
| | os_configs = [ |
| | |
| | { |
| | "platform": "Windows NT 10.0; Win64; x64", |
| | "browsers": [ |
| | lambda r: f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36", |
| | lambda r: f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}", |
| | lambda r: f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36 Edg/{r.choice(edge_versions)}", |
| | ] |
| | }, |
| | |
| | { |
| | "platform": "Macintosh; Intel Mac OS X 10_15_7", |
| | "browsers": [ |
| | lambda r: f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36", |
| | lambda r: f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{r.choice(safari_versions)} Safari/605.1.15", |
| | lambda r: f"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.{r.randint(0, 7)}; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}", |
| | ] |
| | }, |
| | |
| | { |
| | "platform": "X11; Linux x86_64", |
| | "browsers": [ |
| | lambda r: f"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{r.choice(chrome_versions)} Safari/537.36", |
| | lambda r: f"Mozilla/5.0 (X11; Linux x86_64; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}", |
| | lambda r: f"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:{r.choice(firefox_versions).split('.')[0]}.0) Gecko/20100101 Firefox/{r.choice(firefox_versions)}", |
| | ] |
| | } |
| | ] |
| |
|
| | |
| | os_config = rng.choice(os_configs) |
| | browser_generator = rng.choice(os_config["browsers"]) |
| | user_agent = browser_generator(rng) |
| | |
| | |
| | self._user_agent_cache[account_id] = user_agent |
| | |
| | return user_agent |
| |
|
| | async def _make_request( |
| | self, |
| | method: str, |
| | url: str, |
| | headers: Optional[Dict] = None, |
| | json_data: Optional[Dict] = None, |
| | use_st: bool = False, |
| | st_token: Optional[str] = None, |
| | use_at: bool = False, |
| | at_token: Optional[str] = None, |
| | timeout: Optional[int] = None |
| | ) -> Dict[str, Any]: |
| | """统一HTTP请求处理 |
| | |
| | Args: |
| | method: HTTP方法 (GET/POST) |
| | url: 完整URL |
| | headers: 请求头 |
| | json_data: JSON请求体 |
| | use_st: 是否使用ST认证 (Cookie方式) |
| | st_token: Session Token |
| | use_at: 是否使用AT认证 (Bearer方式) |
| | at_token: Access Token |
| | timeout: 自定义超时时间(秒),不传则使用默认值 |
| | """ |
| | proxy_url = await self.proxy_manager.get_proxy_url() |
| | request_timeout = timeout or self.timeout |
| |
|
| | if headers is None: |
| | headers = {} |
| |
|
| | |
| | if use_st and st_token: |
| | headers["Cookie"] = f"__Secure-next-auth.session-token={st_token}" |
| |
|
| | |
| | if use_at and at_token: |
| | headers["authorization"] = f"Bearer {at_token}" |
| |
|
| | |
| | account_id = None |
| | if st_token: |
| | account_id = st_token[:16] |
| | elif at_token: |
| | account_id = at_token[:16] |
| |
|
| | |
| | headers.update({ |
| | "Content-Type": "application/json", |
| | "User-Agent": self._generate_user_agent(account_id) |
| | }) |
| |
|
| | |
| | for key, value in self._default_client_headers.items(): |
| | headers.setdefault(key, value) |
| |
|
| | |
| | if config.debug_enabled: |
| | debug_logger.log_request( |
| | method=method, |
| | url=url, |
| | headers=headers, |
| | body=json_data, |
| | proxy=proxy_url |
| | ) |
| |
|
| | start_time = time.time() |
| |
|
| | try: |
| | async with AsyncSession() as session: |
| | if method.upper() == "GET": |
| | response = await session.get( |
| | url, |
| | headers=headers, |
| | proxy=proxy_url, |
| | timeout=request_timeout, |
| | impersonate="chrome110" |
| | ) |
| | else: |
| | response = await session.post( |
| | url, |
| | headers=headers, |
| | json=json_data, |
| | proxy=proxy_url, |
| | timeout=request_timeout, |
| | impersonate="chrome110" |
| | ) |
| |
|
| | duration_ms = (time.time() - start_time) * 1000 |
| |
|
| | |
| | if config.debug_enabled: |
| | debug_logger.log_response( |
| | status_code=response.status_code, |
| | headers=dict(response.headers), |
| | body=response.text, |
| | duration_ms=duration_ms |
| | ) |
| |
|
| | |
| | if response.status_code >= 400: |
| | |
| | error_reason = f"HTTP Error {response.status_code}" |
| | try: |
| | error_body = response.json() |
| | |
| | if "error" in error_body: |
| | error_info = error_body["error"] |
| | error_message = error_info.get("message", "") |
| | |
| | details = error_info.get("details", []) |
| | for detail in details: |
| | if detail.get("reason"): |
| | error_reason = detail.get("reason") |
| | break |
| | if error_message: |
| | error_reason = f"{error_reason}: {error_message}" |
| | except: |
| | error_reason = f"HTTP Error {response.status_code}: {response.text[:200]}" |
| | |
| | |
| | debug_logger.log_error(f"[API FAILED] URL: {url}") |
| | debug_logger.log_error(f"[API FAILED] Request Body: {json_data}") |
| | debug_logger.log_error(f"[API FAILED] Response: {response.text}") |
| | |
| | raise Exception(error_reason) |
| |
|
| | return response.json() |
| |
|
| | except Exception as e: |
| | duration_ms = (time.time() - start_time) * 1000 |
| | error_msg = str(e) |
| |
|
| | |
| | if "HTTP Error" not in error_msg and not any(x in error_msg for x in ["PUBLIC_ERROR", "INVALID_ARGUMENT"]): |
| | debug_logger.log_error(f"[API FAILED] URL: {url}") |
| | debug_logger.log_error(f"[API FAILED] Request Body: {json_data}") |
| | debug_logger.log_error(f"[API FAILED] Exception: {error_msg}") |
| |
|
| | raise Exception(f"Flow API request failed: {error_msg}") |
| |
|
| | |
| |
|
| | async def st_to_at(self, st: str) -> dict: |
| | """ST转AT |
| | |
| | Args: |
| | st: Session Token |
| | |
| | Returns: |
| | { |
| | "access_token": "AT", |
| | "expires": "2025-11-15T04:46:04.000Z", |
| | "user": {...} |
| | } |
| | """ |
| | url = f"{self.labs_base_url}/auth/session" |
| | result = await self._make_request( |
| | method="GET", |
| | url=url, |
| | use_st=True, |
| | st_token=st |
| | ) |
| | return result |
| |
|
| | |
| |
|
| | async def create_project(self, st: str, title: str) -> str: |
| | """创建项目,返回project_id |
| | |
| | Args: |
| | st: Session Token |
| | title: 项目标题 |
| | |
| | Returns: |
| | project_id (UUID) |
| | """ |
| | url = f"{self.labs_base_url}/trpc/project.createProject" |
| | json_data = { |
| | "json": { |
| | "projectTitle": title, |
| | "toolName": "PINHOLE" |
| | } |
| | } |
| |
|
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_st=True, |
| | st_token=st |
| | ) |
| |
|
| | |
| | project_id = result["result"]["data"]["json"]["result"]["projectId"] |
| | return project_id |
| |
|
| | async def delete_project(self, st: str, project_id: str): |
| | """删除项目 |
| | |
| | Args: |
| | st: Session Token |
| | project_id: 项目ID |
| | """ |
| | url = f"{self.labs_base_url}/trpc/project.deleteProject" |
| | json_data = { |
| | "json": { |
| | "projectToDeleteId": project_id |
| | } |
| | } |
| |
|
| | await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_st=True, |
| | st_token=st |
| | ) |
| |
|
| | |
| |
|
| | async def get_credits(self, at: str) -> dict: |
| | """查询余额 |
| | |
| | Args: |
| | at: Access Token |
| | |
| | Returns: |
| | { |
| | "credits": 920, |
| | "userPaygateTier": "PAYGATE_TIER_ONE" |
| | } |
| | """ |
| | url = f"{self.api_base_url}/credits" |
| | result = await self._make_request( |
| | method="GET", |
| | url=url, |
| | use_at=True, |
| | at_token=at |
| | ) |
| | return result |
| |
|
| | |
| |
|
| | def _detect_image_mime_type(self, image_bytes: bytes) -> str: |
| | """通过文件头 magic bytes 检测图片 MIME 类型 |
| | |
| | Args: |
| | image_bytes: 图片字节数据 |
| | |
| | Returns: |
| | MIME 类型字符串,默认 image/jpeg |
| | """ |
| | if len(image_bytes) < 12: |
| | return "image/jpeg" |
| |
|
| | |
| | if image_bytes[:4] == b'RIFF' and image_bytes[8:12] == b'WEBP': |
| | return "image/webp" |
| | |
| | if image_bytes[:4] == b'\x89PNG': |
| | return "image/png" |
| | |
| | if image_bytes[:3] == b'\xff\xd8\xff': |
| | return "image/jpeg" |
| | |
| | if image_bytes[:6] in (b'GIF87a', b'GIF89a'): |
| | return "image/gif" |
| | |
| | if image_bytes[:2] == b'BM': |
| | return "image/bmp" |
| | |
| | if image_bytes[:6] == b'\x00\x00\x00\x0cjP': |
| | return "image/jp2" |
| |
|
| | return "image/jpeg" |
| |
|
| | def _convert_to_jpeg(self, image_bytes: bytes) -> bytes: |
| | """将图片转换为 JPEG 格式 |
| | |
| | Args: |
| | image_bytes: 原始图片字节数据 |
| | |
| | Returns: |
| | JPEG 格式的图片字节数据 |
| | """ |
| | from io import BytesIO |
| | from PIL import Image |
| |
|
| | img = Image.open(BytesIO(image_bytes)) |
| | |
| | if img.mode in ('RGBA', 'LA', 'P'): |
| | img = img.convert('RGB') |
| | |
| | output = BytesIO() |
| | img.save(output, format='JPEG', quality=95) |
| | return output.getvalue() |
| |
|
| | async def upload_image( |
| | self, |
| | at: str, |
| | image_bytes: bytes, |
| | aspect_ratio: str = "IMAGE_ASPECT_RATIO_LANDSCAPE" |
| | ) -> str: |
| | """上传图片,返回mediaGenerationId |
| | |
| | Args: |
| | at: Access Token |
| | image_bytes: 图片字节数据 |
| | aspect_ratio: 图片或视频宽高比(会自动转换为图片格式) |
| | |
| | Returns: |
| | mediaGenerationId (CAM...) |
| | """ |
| | |
| | |
| | |
| | if aspect_ratio.startswith("VIDEO_"): |
| | aspect_ratio = aspect_ratio.replace("VIDEO_", "IMAGE_") |
| |
|
| | |
| | mime_type = self._detect_image_mime_type(image_bytes) |
| |
|
| | |
| | image_base64 = base64.b64encode(image_bytes).decode('utf-8') |
| |
|
| | url = f"{self.api_base_url}:uploadUserImage" |
| | json_data = { |
| | "imageInput": { |
| | "rawImageBytes": image_base64, |
| | "mimeType": mime_type, |
| | "isUserUploaded": True, |
| | "aspectRatio": aspect_ratio |
| | }, |
| | "clientContext": { |
| | "sessionId": self._generate_session_id(), |
| | "tool": "ASSET_MANAGER" |
| | } |
| | } |
| |
|
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_at=True, |
| | at_token=at |
| | ) |
| |
|
| | |
| | media_id = result["mediaGenerationId"]["mediaGenerationId"] |
| | return media_id |
| |
|
| | |
| |
|
| | async def generate_image( |
| | self, |
| | at: str, |
| | project_id: str, |
| | prompt: str, |
| | model_name: str, |
| | aspect_ratio: str, |
| | image_inputs: Optional[List[Dict]] = None |
| | ) -> dict: |
| | """生成图片(同步返回) |
| | |
| | Args: |
| | at: Access Token |
| | project_id: 项目ID |
| | prompt: 提示词 |
| | model_name: GEM_PIX, GEM_PIX_2 或 IMAGEN_3_5 |
| | aspect_ratio: 图片宽高比 |
| | image_inputs: 参考图片列表(图生图时使用) |
| | |
| | Returns: |
| | { |
| | "media": [{ |
| | "image": { |
| | "generatedImage": { |
| | "fifeUrl": "图片URL", |
| | ... |
| | } |
| | } |
| | }] |
| | } |
| | """ |
| | url = f"{self.api_base_url}/projects/{project_id}/flowMedia:batchGenerateImages" |
| |
|
| | |
| | max_retries = 3 |
| | last_error = None |
| | |
| | for retry_attempt in range(max_retries): |
| | |
| | recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="IMAGE_GENERATION") |
| | if not recaptcha_token: |
| | raise Exception("Failed to obtain reCAPTCHA token") |
| | session_id = self._generate_session_id() |
| |
|
| | |
| | client_context = { |
| | "recaptchaContext": { |
| | "token": recaptcha_token, |
| | "applicationType": "RECAPTCHA_APPLICATION_TYPE_WEB" |
| | }, |
| | "sessionId": session_id, |
| | "projectId": project_id, |
| | "tool": "PINHOLE" |
| | } |
| |
|
| | request_data = { |
| | "seed": random.randint(1, 99999), |
| | "imageModelName": model_name, |
| | "imageAspectRatio": aspect_ratio, |
| | "prompt": prompt, |
| | "imageInputs": image_inputs or [] |
| | } |
| |
|
| | json_data = { |
| | "clientContext": client_context, |
| | "requests": [request_data] |
| | } |
| |
|
| | try: |
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_at=True, |
| | at_token=at |
| | ) |
| | return result |
| | except Exception as e: |
| | error_str = str(e) |
| | last_error = e |
| | retry_reason = self._get_retry_reason(error_str) |
| | if retry_reason and retry_attempt < max_retries - 1: |
| | debug_logger.log_warning(f"[IMAGE] 生成遇到{retry_reason},正在重新获取验证码重试 ({retry_attempt + 2}/{max_retries})...") |
| | await self._notify_browser_captcha_error(browser_id) |
| | await asyncio.sleep(1) |
| | continue |
| | else: |
| | raise e |
| | |
| | |
| | raise last_error |
| |
|
| | async def upsample_image( |
| | self, |
| | at: str, |
| | project_id: str, |
| | media_id: str, |
| | target_resolution: str = "UPSAMPLE_IMAGE_RESOLUTION_4K" |
| | ) -> str: |
| | """放大图片到 2K/4K |
| | |
| | Args: |
| | at: Access Token |
| | project_id: 项目ID |
| | media_id: 图片的 mediaId (从 batchGenerateImages 返回的 media[0]["name"]) |
| | target_resolution: UPSAMPLE_IMAGE_RESOLUTION_2K 或 UPSAMPLE_IMAGE_RESOLUTION_4K |
| | |
| | Returns: |
| | base64 编码的图片数据 |
| | """ |
| | url = f"{self.api_base_url}/flow/upsampleImage" |
| |
|
| | |
| | recaptcha_token, _ = await self._get_recaptcha_token(project_id, action="IMAGE_GENERATION") |
| | if not recaptcha_token: |
| | raise Exception("Failed to obtain reCAPTCHA token") |
| | session_id = self._generate_session_id() |
| |
|
| | json_data = { |
| | "mediaId": media_id, |
| | "targetResolution": target_resolution, |
| | "clientContext": { |
| | "recaptchaContext": { |
| | "token": recaptcha_token, |
| | "applicationType": "RECAPTCHA_APPLICATION_TYPE_WEB" |
| | }, |
| | "sessionId": session_id, |
| | "projectId": project_id, |
| | "tool": "PINHOLE" |
| | } |
| | } |
| |
|
| | |
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_at=True, |
| | at_token=at, |
| | timeout=config.upsample_timeout |
| | ) |
| |
|
| | |
| | return result.get("encodedImage", "") |
| |
|
| | |
| |
|
| | async def generate_video_text( |
| | self, |
| | at: str, |
| | project_id: str, |
| | prompt: str, |
| | model_key: str, |
| | aspect_ratio: str, |
| | user_paygate_tier: str = "PAYGATE_TIER_ONE" |
| | ) -> dict: |
| | """文生视频,返回task_id |
| | |
| | Args: |
| | at: Access Token |
| | project_id: 项目ID |
| | prompt: 提示词 |
| | model_key: veo_3_1_t2v_fast 等 |
| | aspect_ratio: 视频宽高比 |
| | user_paygate_tier: 用户等级 |
| | |
| | Returns: |
| | { |
| | "operations": [{ |
| | "operation": {"name": "task_id"}, |
| | "sceneId": "uuid", |
| | "status": "MEDIA_GENERATION_STATUS_PENDING" |
| | }], |
| | "remainingCredits": 900 |
| | } |
| | """ |
| | url = f"{self.api_base_url}/video:batchAsyncGenerateVideoText" |
| |
|
| | |
| | max_retries = 3 |
| | last_error = None |
| | |
| | for retry_attempt in range(max_retries): |
| | |
| | recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") |
| | if not recaptcha_token: |
| | raise Exception("Failed to obtain reCAPTCHA token") |
| | session_id = self._generate_session_id() |
| | scene_id = str(uuid.uuid4()) |
| |
|
| | json_data = { |
| | "clientContext": { |
| | "recaptchaContext": { |
| | "token": recaptcha_token, |
| | "applicationType": "RECAPTCHA_APPLICATION_TYPE_WEB" |
| | }, |
| | "sessionId": session_id, |
| | "projectId": project_id, |
| | "tool": "PINHOLE", |
| | "userPaygateTier": user_paygate_tier |
| | }, |
| | "requests": [{ |
| | "aspectRatio": aspect_ratio, |
| | "seed": random.randint(1, 99999), |
| | "textInput": { |
| | "prompt": prompt |
| | }, |
| | "videoModelKey": model_key, |
| | "metadata": { |
| | "sceneId": scene_id |
| | } |
| | }] |
| | } |
| |
|
| | try: |
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_at=True, |
| | at_token=at |
| | ) |
| | return result |
| | except Exception as e: |
| | error_str = str(e) |
| | last_error = e |
| | retry_reason = self._get_retry_reason(error_str) |
| | if retry_reason and retry_attempt < max_retries - 1: |
| | debug_logger.log_warning(f"[VIDEO T2V] 生成遇到{retry_reason},正在重新获取验证码重试 ({retry_attempt + 2}/{max_retries})...") |
| | await self._notify_browser_captcha_error(browser_id) |
| | await asyncio.sleep(1) |
| | continue |
| | else: |
| | raise e |
| | |
| | |
| | raise last_error |
| |
|
| | async def generate_video_reference_images( |
| | self, |
| | at: str, |
| | project_id: str, |
| | prompt: str, |
| | model_key: str, |
| | aspect_ratio: str, |
| | reference_images: List[Dict], |
| | user_paygate_tier: str = "PAYGATE_TIER_ONE" |
| | ) -> dict: |
| | """图生视频,返回task_id |
| | |
| | Args: |
| | at: Access Token |
| | project_id: 项目ID |
| | prompt: 提示词 |
| | model_key: veo_3_0_r2v_fast |
| | aspect_ratio: 视频宽高比 |
| | reference_images: 参考图片列表 [{"imageUsageType": "IMAGE_USAGE_TYPE_ASSET", "mediaId": "..."}] |
| | user_paygate_tier: 用户等级 |
| | |
| | Returns: |
| | 同 generate_video_text |
| | """ |
| | url = f"{self.api_base_url}/video:batchAsyncGenerateVideoReferenceImages" |
| |
|
| | |
| | max_retries = 3 |
| | last_error = None |
| | |
| | for retry_attempt in range(max_retries): |
| | |
| | recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") |
| | if not recaptcha_token: |
| | raise Exception("Failed to obtain reCAPTCHA token") |
| | session_id = self._generate_session_id() |
| | scene_id = str(uuid.uuid4()) |
| |
|
| | json_data = { |
| | "clientContext": { |
| | "recaptchaContext": { |
| | "token": recaptcha_token, |
| | "applicationType": "RECAPTCHA_APPLICATION_TYPE_WEB" |
| | }, |
| | "sessionId": session_id, |
| | "projectId": project_id, |
| | "tool": "PINHOLE", |
| | "userPaygateTier": user_paygate_tier |
| | }, |
| | "requests": [{ |
| | "aspectRatio": aspect_ratio, |
| | "seed": random.randint(1, 99999), |
| | "textInput": { |
| | "prompt": prompt |
| | }, |
| | "videoModelKey": model_key, |
| | "referenceImages": reference_images, |
| | "metadata": { |
| | "sceneId": scene_id |
| | } |
| | }] |
| | } |
| |
|
| | try: |
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_at=True, |
| | at_token=at |
| | ) |
| | return result |
| | except Exception as e: |
| | error_str = str(e) |
| | last_error = e |
| | retry_reason = self._get_retry_reason(error_str) |
| | if retry_reason and retry_attempt < max_retries - 1: |
| | debug_logger.log_warning(f"[VIDEO R2V] 生成遇到{retry_reason},正在重新获取验证码重试 ({retry_attempt + 2}/{max_retries})...") |
| | await self._notify_browser_captcha_error(browser_id) |
| | await asyncio.sleep(1) |
| | continue |
| | else: |
| | raise e |
| | |
| | |
| | raise last_error |
| |
|
| | async def generate_video_start_end( |
| | self, |
| | at: str, |
| | project_id: str, |
| | prompt: str, |
| | model_key: str, |
| | aspect_ratio: str, |
| | start_media_id: str, |
| | end_media_id: str, |
| | user_paygate_tier: str = "PAYGATE_TIER_ONE" |
| | ) -> dict: |
| | """收尾帧生成视频,返回task_id |
| | |
| | Args: |
| | at: Access Token |
| | project_id: 项目ID |
| | prompt: 提示词 |
| | model_key: veo_3_1_i2v_s_fast_fl |
| | aspect_ratio: 视频宽高比 |
| | start_media_id: 起始帧mediaId |
| | end_media_id: 结束帧mediaId |
| | user_paygate_tier: 用户等级 |
| | |
| | Returns: |
| | 同 generate_video_text |
| | """ |
| | url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartAndEndImage" |
| |
|
| | |
| | max_retries = 3 |
| | last_error = None |
| | |
| | for retry_attempt in range(max_retries): |
| | |
| | recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") |
| | if not recaptcha_token: |
| | raise Exception("Failed to obtain reCAPTCHA token") |
| | session_id = self._generate_session_id() |
| | scene_id = str(uuid.uuid4()) |
| |
|
| | json_data = { |
| | "clientContext": { |
| | "recaptchaContext": { |
| | "token": recaptcha_token, |
| | "applicationType": "RECAPTCHA_APPLICATION_TYPE_WEB" |
| | }, |
| | "sessionId": session_id, |
| | "projectId": project_id, |
| | "tool": "PINHOLE", |
| | "userPaygateTier": user_paygate_tier |
| | }, |
| | "requests": [{ |
| | "aspectRatio": aspect_ratio, |
| | "seed": random.randint(1, 99999), |
| | "textInput": { |
| | "prompt": prompt |
| | }, |
| | "videoModelKey": model_key, |
| | "startImage": { |
| | "mediaId": start_media_id |
| | }, |
| | "endImage": { |
| | "mediaId": end_media_id |
| | }, |
| | "metadata": { |
| | "sceneId": scene_id |
| | } |
| | }] |
| | } |
| |
|
| | try: |
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_at=True, |
| | at_token=at |
| | ) |
| | return result |
| | except Exception as e: |
| | error_str = str(e) |
| | last_error = e |
| | retry_reason = self._get_retry_reason(error_str) |
| | if retry_reason and retry_attempt < max_retries - 1: |
| | debug_logger.log_warning(f"[VIDEO I2V] 首尾帧生成遇到{retry_reason},正在重新获取验证码重试 ({retry_attempt + 2}/{max_retries})...") |
| | await self._notify_browser_captcha_error(browser_id) |
| | await asyncio.sleep(1) |
| | continue |
| | else: |
| | raise e |
| | |
| | |
| | raise last_error |
| |
|
| | async def generate_video_start_image( |
| | self, |
| | at: str, |
| | project_id: str, |
| | prompt: str, |
| | model_key: str, |
| | aspect_ratio: str, |
| | start_media_id: str, |
| | user_paygate_tier: str = "PAYGATE_TIER_ONE" |
| | ) -> dict: |
| | """仅首帧生成视频,返回task_id |
| | |
| | Args: |
| | at: Access Token |
| | project_id: 项目ID |
| | prompt: 提示词 |
| | model_key: veo_3_1_i2v_s_fast_fl等 |
| | aspect_ratio: 视频宽高比 |
| | start_media_id: 起始帧mediaId |
| | user_paygate_tier: 用户等级 |
| | |
| | Returns: |
| | 同 generate_video_text |
| | """ |
| | url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartImage" |
| |
|
| | |
| | max_retries = 3 |
| | last_error = None |
| | |
| | for retry_attempt in range(max_retries): |
| | |
| | recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") |
| | if not recaptcha_token: |
| | raise Exception("Failed to obtain reCAPTCHA token") |
| | session_id = self._generate_session_id() |
| | scene_id = str(uuid.uuid4()) |
| |
|
| | json_data = { |
| | "clientContext": { |
| | "recaptchaContext": { |
| | "token": recaptcha_token, |
| | "applicationType": "RECAPTCHA_APPLICATION_TYPE_WEB" |
| | }, |
| | "sessionId": session_id, |
| | "projectId": project_id, |
| | "tool": "PINHOLE", |
| | "userPaygateTier": user_paygate_tier |
| | }, |
| | "requests": [{ |
| | "aspectRatio": aspect_ratio, |
| | "seed": random.randint(1, 99999), |
| | "textInput": { |
| | "prompt": prompt |
| | }, |
| | "videoModelKey": model_key, |
| | "startImage": { |
| | "mediaId": start_media_id |
| | }, |
| | |
| | "metadata": { |
| | "sceneId": scene_id |
| | } |
| | }] |
| | } |
| |
|
| | try: |
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_at=True, |
| | at_token=at |
| | ) |
| | return result |
| | except Exception as e: |
| | error_str = str(e) |
| | last_error = e |
| | retry_reason = self._get_retry_reason(error_str) |
| | if retry_reason and retry_attempt < max_retries - 1: |
| | debug_logger.log_warning(f"[VIDEO I2V] 首帧生成遇到{retry_reason},正在重新获取验证码重试 ({retry_attempt + 2}/{max_retries})...") |
| | await self._notify_browser_captcha_error(browser_id) |
| | await asyncio.sleep(1) |
| | continue |
| | else: |
| | raise e |
| | |
| | |
| | raise last_error |
| |
|
| | |
| |
|
| | async def upsample_video( |
| | self, |
| | at: str, |
| | project_id: str, |
| | video_media_id: str, |
| | aspect_ratio: str, |
| | resolution: str, |
| | model_key: str |
| | ) -> dict: |
| | """视频放大到 4K/1080P,返回 task_id |
| | |
| | Args: |
| | at: Access Token |
| | project_id: 项目ID |
| | video_media_id: 视频的 mediaId |
| | aspect_ratio: 视频宽高比 VIDEO_ASPECT_RATIO_PORTRAIT/LANDSCAPE |
| | resolution: VIDEO_RESOLUTION_4K 或 VIDEO_RESOLUTION_1080P |
| | model_key: veo_3_1_upsampler_4k 或 veo_3_1_upsampler_1080p |
| | |
| | Returns: |
| | 同 generate_video_text |
| | """ |
| | url = f"{self.api_base_url}/video:batchAsyncGenerateVideoUpsampleVideo" |
| |
|
| | |
| | max_retries = 3 |
| | last_error = None |
| | |
| | for retry_attempt in range(max_retries): |
| | recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") |
| | if not recaptcha_token: |
| | raise Exception("Failed to obtain reCAPTCHA token") |
| | session_id = self._generate_session_id() |
| | scene_id = str(uuid.uuid4()) |
| |
|
| | json_data = { |
| | "requests": [{ |
| | "aspectRatio": aspect_ratio, |
| | "resolution": resolution, |
| | "seed": random.randint(1, 99999), |
| | "videoInput": { |
| | "mediaId": video_media_id |
| | }, |
| | "videoModelKey": model_key, |
| | "metadata": { |
| | "sceneId": scene_id |
| | } |
| | }], |
| | "clientContext": { |
| | "recaptchaContext": { |
| | "token": recaptcha_token, |
| | "applicationType": "RECAPTCHA_APPLICATION_TYPE_WEB" |
| | }, |
| | "sessionId": session_id |
| | } |
| | } |
| |
|
| | try: |
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_at=True, |
| | at_token=at |
| | ) |
| | return result |
| | except Exception as e: |
| | error_str = str(e) |
| | last_error = e |
| | retry_reason = self._get_retry_reason(error_str) |
| | if retry_reason and retry_attempt < max_retries - 1: |
| | debug_logger.log_warning(f"[VIDEO UPSAMPLE] 放大遇到{retry_reason},正在重新获取验证码重试 ({retry_attempt + 2}/{max_retries})...") |
| | await self._notify_browser_captcha_error(browser_id) |
| | await asyncio.sleep(1) |
| | continue |
| | else: |
| | raise e |
| | |
| | raise last_error |
| |
|
| | |
| |
|
| | async def check_video_status(self, at: str, operations: List[Dict]) -> dict: |
| | """查询视频生成状态 |
| | |
| | Args: |
| | at: Access Token |
| | operations: 操作列表 [{"operation": {"name": "task_id"}, "sceneId": "...", "status": "..."}] |
| | |
| | Returns: |
| | { |
| | "operations": [{ |
| | "operation": { |
| | "name": "task_id", |
| | "metadata": {...} # 完成时包含视频信息 |
| | }, |
| | "status": "MEDIA_GENERATION_STATUS_SUCCESSFUL" |
| | }] |
| | } |
| | """ |
| | url = f"{self.api_base_url}/video:batchCheckAsyncVideoGenerationStatus" |
| |
|
| | json_data = { |
| | "operations": operations |
| | } |
| |
|
| | result = await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_at=True, |
| | at_token=at |
| | ) |
| |
|
| | return result |
| |
|
| | |
| |
|
| | async def delete_media(self, st: str, media_names: List[str]): |
| | """删除媒体 |
| | |
| | Args: |
| | st: Session Token |
| | media_names: 媒体ID列表 |
| | """ |
| | url = f"{self.labs_base_url}/trpc/media.deleteMedia" |
| | json_data = { |
| | "json": { |
| | "names": media_names |
| | } |
| | } |
| |
|
| | await self._make_request( |
| | method="POST", |
| | url=url, |
| | json_data=json_data, |
| | use_st=True, |
| | st_token=st |
| | ) |
| |
|
| | |
| |
|
| | def _get_retry_reason(self, error_str: str) -> Optional[str]: |
| | """判断是否需要重试,返回日志提示内容""" |
| | error_lower = error_str.lower() |
| | if "403" in error_lower: |
| | return "403错误" |
| | if "recaptcha evaluation failed" in error_lower: |
| | return "reCAPTCHA 验证失败" |
| | if "recaptcha" in error_lower: |
| | return "reCAPTCHA 错误" |
| | return None |
| |
|
| | async def _notify_browser_captcha_error(self, browser_id: int = None): |
| | """通知有头浏览器打码切换指纹(仅当使用 browser 打码方式时) |
| | |
| | Args: |
| | browser_id: 要标记为 bad 的浏览器 ID |
| | """ |
| | if config.captcha_method == "browser": |
| | try: |
| | from .browser_captcha import BrowserCaptchaService |
| | service = await BrowserCaptchaService.get_instance(self.db) |
| | await service.report_error(browser_id) |
| | except Exception: |
| | pass |
| |
|
| | def _generate_session_id(self) -> str: |
| | """生成sessionId: ;timestamp""" |
| | return f";{int(time.time() * 1000)}" |
| |
|
| | def _generate_scene_id(self) -> str: |
| | """生成sceneId: UUID""" |
| | return str(uuid.uuid4()) |
| |
|
| | async def _get_recaptcha_token(self, project_id: str, action: str = "IMAGE_GENERATION") -> tuple[Optional[str], Optional[int]]: |
| | """获取reCAPTCHA token - 支持多种打码方式 |
| | |
| | Args: |
| | project_id: 项目ID |
| | action: reCAPTCHA action类型 |
| | - IMAGE_GENERATION: 图片生成和2K/4K图片放大 (默认) |
| | - VIDEO_GENERATION: 视频生成和视频放大 |
| | |
| | Returns: |
| | (token, browser_id) 元组,browser_id 用于失败时调用 report_error |
| | 对于非 browser 打码方式,browser_id 为 None |
| | """ |
| | captcha_method = config.captcha_method |
| |
|
| | |
| | if captcha_method == "personal": |
| | try: |
| | from .browser_captcha_personal import BrowserCaptchaService |
| | service = await BrowserCaptchaService.get_instance(self.db) |
| | return await service.get_token(project_id, action), None |
| | except RuntimeError as e: |
| | |
| | error_msg = str(e) |
| | debug_logger.log_error(f"[reCAPTCHA Personal] {error_msg}") |
| | print(f"[reCAPTCHA] ❌ 内置浏览器打码失败: {error_msg}") |
| | return None, None |
| | except ImportError as e: |
| | debug_logger.log_error(f"[reCAPTCHA Personal] 导入失败: {str(e)}") |
| | print(f"[reCAPTCHA] ❌ nodriver 未安装,请运行: pip install nodriver") |
| | return None, None |
| | except Exception as e: |
| | debug_logger.log_error(f"[reCAPTCHA Personal] 错误: {str(e)}") |
| | return None, None |
| | |
| | elif captcha_method == "browser": |
| | try: |
| | from .browser_captcha import BrowserCaptchaService |
| | service = await BrowserCaptchaService.get_instance(self.db) |
| | return await service.get_token(project_id, action) |
| | except RuntimeError as e: |
| | |
| | error_msg = str(e) |
| | debug_logger.log_error(f"[reCAPTCHA Browser] {error_msg}") |
| | print(f"[reCAPTCHA] ❌ 有头浏览器打码失败: {error_msg}") |
| | return None, None |
| | except ImportError as e: |
| | debug_logger.log_error(f"[reCAPTCHA Browser] 导入失败: {str(e)}") |
| | print(f"[reCAPTCHA] ❌ playwright 未安装,请运行: pip install playwright && python -m playwright install chromium") |
| | return None, None |
| | except Exception as e: |
| | debug_logger.log_error(f"[reCAPTCHA Browser] 错误: {str(e)}") |
| | return None, None |
| | |
| | elif captcha_method in ["yescaptcha", "capmonster", "ezcaptcha", "capsolver"]: |
| | token = await self._get_api_captcha_token(captcha_method, project_id, action) |
| | return token, None |
| | else: |
| | debug_logger.log_info(f"[reCAPTCHA] 未知的打码方式: {captcha_method}") |
| | return None, None |
| |
|
| | async def _get_api_captcha_token(self, method: str, project_id: str, action: str = "IMAGE_GENERATION") -> Optional[str]: |
| | """通用API打码服务 |
| | |
| | Args: |
| | method: 打码服务类型 |
| | project_id: 项目ID |
| | action: reCAPTCHA action类型 (IMAGE_GENERATION 或 VIDEO_GENERATION) |
| | """ |
| | |
| | if method == "yescaptcha": |
| | client_key = config.yescaptcha_api_key |
| | base_url = config.yescaptcha_base_url |
| | task_type = "RecaptchaV3TaskProxylessM1" |
| | elif method == "capmonster": |
| | client_key = config.capmonster_api_key |
| | base_url = config.capmonster_base_url |
| | task_type = "RecaptchaV3TaskProxyless" |
| | elif method == "ezcaptcha": |
| | client_key = config.ezcaptcha_api_key |
| | base_url = config.ezcaptcha_base_url |
| | task_type = "ReCaptchaV3TaskProxylessS9" |
| | elif method == "capsolver": |
| | client_key = config.capsolver_api_key |
| | base_url = config.capsolver_base_url |
| | task_type = "ReCaptchaV3EnterpriseTaskProxyLess" |
| | else: |
| | debug_logger.log_error(f"[reCAPTCHA] Unknown API method: {method}") |
| | return None |
| |
|
| | if not client_key: |
| | debug_logger.log_info(f"[reCAPTCHA] {method} API key not configured, skipping") |
| | return None |
| |
|
| | website_key = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV" |
| | website_url = f"https://labs.google/fx/tools/flow/project/{project_id}" |
| | page_action = action |
| |
|
| | try: |
| | async with AsyncSession() as session: |
| | create_url = f"{base_url}/createTask" |
| | create_data = { |
| | "clientKey": client_key, |
| | "task": { |
| | "websiteURL": website_url, |
| | "websiteKey": website_key, |
| | "type": task_type, |
| | "pageAction": page_action |
| | } |
| | } |
| |
|
| | result = await session.post(create_url, json=create_data, impersonate="chrome110") |
| | result_json = result.json() |
| | task_id = result_json.get('taskId') |
| |
|
| | debug_logger.log_info(f"[reCAPTCHA {method}] created task_id: {task_id}") |
| |
|
| | if not task_id: |
| | error_desc = result_json.get('errorDescription', 'Unknown error') |
| | debug_logger.log_error(f"[reCAPTCHA {method}] Failed to create task: {error_desc}") |
| | return None |
| |
|
| | get_url = f"{base_url}/getTaskResult" |
| | for i in range(40): |
| | get_data = { |
| | "clientKey": client_key, |
| | "taskId": task_id |
| | } |
| | result = await session.post(get_url, json=get_data, impersonate="chrome110") |
| | result_json = result.json() |
| |
|
| | debug_logger.log_info(f"[reCAPTCHA {method}] polling #{i+1}: {result_json}") |
| |
|
| | status = result_json.get('status') |
| | if status == 'ready': |
| | solution = result_json.get('solution', {}) |
| | response = solution.get('gRecaptchaResponse') |
| | if response: |
| | debug_logger.log_info(f"[reCAPTCHA {method}] Token获取成功") |
| | return response |
| |
|
| | time.sleep(3) |
| |
|
| | debug_logger.log_error(f"[reCAPTCHA {method}] Timeout waiting for token") |
| | return None |
| |
|
| | except Exception as e: |
| | debug_logger.log_error(f"[reCAPTCHA {method}] error: {str(e)}") |
| | return None |
| |
|