| """Flow API Client for VideoFX (Veo)""" |
| 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): |
| self.proxy_manager = proxy_manager |
| self.labs_base_url = config.flow_labs_base_url |
| self.api_base_url = config.flow_api_base_url |
| self.timeout = config.flow_timeout |
|
|
| 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 |
| ) -> 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 |
| """ |
| proxy_url = await self.proxy_manager.get_proxy_url() |
|
|
| 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}" |
|
|
| |
| headers.update({ |
| "Content-Type": "application/json", |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" |
| }) |
|
|
| |
| 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=self.timeout, |
| impersonate="chrome110" |
| ) |
| else: |
| response = await session.post( |
| url, |
| headers=headers, |
| json=json_data, |
| proxy=proxy_url, |
| timeout=self.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 |
| ) |
|
|
| response.raise_for_status() |
| return response.json() |
|
|
| except Exception as e: |
| duration_ms = (time.time() - start_time) * 1000 |
| error_msg = str(e) |
|
|
| if config.debug_enabled: |
| debug_logger.log_error( |
| error_message=error_msg, |
| status_code=getattr(e, 'status_code', None), |
| response_text=getattr(e, 'response_text', None) |
| ) |
|
|
| 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 |
|
|
| |
|
|
| 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_") |
|
|
| |
| image_base64 = base64.b64encode(image_bytes).decode('utf-8') |
|
|
| url = f"{self.api_base_url}:uploadUserImage" |
| json_data = { |
| "imageInput": { |
| "rawImageBytes": image_base64, |
| "mimeType": "image/jpeg", |
| "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" |
|
|
| |
| recaptcha_token = await self._get_recaptcha_token(project_id) or "" |
| session_id = self._generate_session_id() |
|
|
| |
| request_data = { |
| "clientContext": { |
| "recaptchaToken": recaptcha_token, |
| "projectId": project_id, |
| "sessionId": session_id, |
| "tool": "PINHOLE" |
| }, |
| "seed": random.randint(1, 99999), |
| "imageModelName": model_name, |
| "imageAspectRatio": aspect_ratio, |
| "prompt": prompt, |
| "imageInputs": image_inputs or [] |
| } |
|
|
| json_data = { |
| "clientContext": { |
| "recaptchaToken": recaptcha_token, |
| "sessionId": session_id |
| }, |
| "requests": [request_data] |
| } |
|
|
| result = await self._make_request( |
| method="POST", |
| url=url, |
| json_data=json_data, |
| use_at=True, |
| at_token=at |
| ) |
|
|
| return result |
|
|
| |
|
|
| 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" |
|
|
| |
| recaptcha_token = await self._get_recaptcha_token(project_id) or "" |
| session_id = self._generate_session_id() |
| scene_id = str(uuid.uuid4()) |
|
|
| json_data = { |
| "clientContext": { |
| "recaptchaToken": recaptcha_token, |
| "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 |
| } |
| }] |
| } |
|
|
| result = await self._make_request( |
| method="POST", |
| url=url, |
| json_data=json_data, |
| use_at=True, |
| at_token=at |
| ) |
|
|
| return result |
|
|
| 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" |
|
|
| |
| recaptcha_token = await self._get_recaptcha_token(project_id) or "" |
| session_id = self._generate_session_id() |
| scene_id = str(uuid.uuid4()) |
|
|
| json_data = { |
| "clientContext": { |
| "recaptchaToken": recaptcha_token, |
| "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 |
| } |
| }] |
| } |
|
|
| result = await self._make_request( |
| method="POST", |
| url=url, |
| json_data=json_data, |
| use_at=True, |
| at_token=at |
| ) |
|
|
| return result |
|
|
| 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" |
|
|
| |
| recaptcha_token = await self._get_recaptcha_token(project_id) or "" |
| session_id = self._generate_session_id() |
| scene_id = str(uuid.uuid4()) |
|
|
| json_data = { |
| "clientContext": { |
| "recaptchaToken": recaptcha_token, |
| "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 |
| } |
| }] |
| } |
|
|
| result = await self._make_request( |
| method="POST", |
| url=url, |
| json_data=json_data, |
| use_at=True, |
| at_token=at |
| ) |
|
|
| return result |
|
|
| 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:batchAsyncGenerateVideoStartAndEndImage" |
|
|
| |
| recaptcha_token = await self._get_recaptcha_token(project_id) or "" |
| session_id = self._generate_session_id() |
| scene_id = str(uuid.uuid4()) |
|
|
| json_data = { |
| "clientContext": { |
| "recaptchaToken": recaptcha_token, |
| "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 |
| } |
| }] |
| } |
|
|
| result = await self._make_request( |
| method="POST", |
| url=url, |
| json_data=json_data, |
| use_at=True, |
| at_token=at |
| ) |
|
|
| return result |
|
|
| |
|
|
| 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 _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) -> Optional[str]: |
| """获取reCAPTCHA token - 支持两种方式""" |
| captcha_method = config.captcha_method |
|
|
| |
| if captcha_method == "personal": |
| try: |
| from .browser_captcha_personal import BrowserCaptchaService |
| service = await BrowserCaptchaService.get_instance(self.proxy_manager) |
| return await service.get_token(project_id) |
| except Exception as e: |
| debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}") |
| return None |
| |
| elif captcha_method == "browser": |
| try: |
| from .browser_captcha import BrowserCaptchaService |
| service = await BrowserCaptchaService.get_instance(self.proxy_manager) |
| return await service.get_token(project_id) |
| except Exception as e: |
| debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}") |
| return None |
| else: |
| |
| client_key = config.yescaptcha_api_key |
| if not client_key: |
| debug_logger.log_info("[reCAPTCHA] API key not configured, skipping") |
| return None |
|
|
| website_key = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV" |
| website_url = f"https://labs.google/fx/tools/flow/project/{project_id}" |
| base_url = config.yescaptcha_base_url |
| page_action = "FLOW_GENERATION" |
|
|
| 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": "RecaptchaV3TaskProxylessM1", |
| "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] created task_id: {task_id}") |
|
|
| if not task_id: |
| 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] polling #{i+1}: {result_json}") |
|
|
| solution = result_json.get('solution', {}) |
| response = solution.get('gRecaptchaResponse') |
|
|
| if response: |
| return response |
|
|
| time.sleep(3) |
|
|
| return None |
|
|
| except Exception as e: |
| debug_logger.log_error(f"[reCAPTCHA] error: {str(e)}") |
| return None |
|
|