genz27 Warp commited on
Commit
8358864
·
1 Parent(s): 6e0803d

feat: 添加浏览器打码依赖自动安装和Docker环境检测

Browse files

- browser_captcha.py: 添加patchright和chromium自动安装(官方源优先,国内镜像备用)
- browser_captcha_personal.py: 添加nodriver自动安装(官方源优先,国内镜像备用)
- 两个模块都添加Docker环境检测,Docker中禁用本地浏览器打码
- flow_client.py: 更新错误处理,提供更清晰的安装指引
- .gitignore: 添加browser_data_rt目录

Co-Authored-By: Warp <agent@warp.dev>

.gitignore CHANGED
@@ -54,6 +54,7 @@ logs.txt
54
  *.cache
55
 
56
  browser_data
 
57
 
58
  data
59
  config/setting.toml
 
54
  *.cache
55
 
56
  browser_data
57
+ browser_data_rt
58
 
59
  data
60
  config/setting.toml
.lh/.lhignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # list file to not track by the local-history extension. comment line starts with a '#' character
2
+ # each line describe a regular expression pattern (search for 'Javascript regex')
3
+ # it will relate to the workspace directory root. for example:
4
+ # '.*\.txt' ignores any file with 'txt' extension
5
+ # '/test/.*' ignores all the files under the 'test' directory
6
+ # '.*/test/.*' ignores all the files under any 'test' directory (even under sub-folders)
.lh/src/services/generation_handler.py.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "sourceFile": "src/services/generation_handler.py",
3
+ "activeCommit": 0,
4
+ "commits": [
5
+ {
6
+ "activePatchIndex": 0,
7
+ "patches": [
8
+ {
9
+ "date": 1770134305752,
10
+ "content": "Index: \n===================================================================\n--- \n+++ \n"
11
+ }
12
+ ],
13
+ "date": 1770134305751,
14
+ "name": "Commit-0",
15
+ "content": "\"\"\"Generation handler for Flow2API\"\"\"\r\nimport asyncio\r\nimport base64\r\nimport json\r\nimport time\r\nfrom typing import Optional, AsyncGenerator, List, Dict, Any\r\nfrom ..core.logger import debug_logger\r\nfrom ..core.config import config\r\nfrom ..core.models import Task, RequestLog\r\nfrom .file_cache import FileCache\r\n\r\n\r\n# Model configuration\r\nMODEL_CONFIG = {\r\n # 图片生成 - GEM_PIX (Gemini 2.5 Flash)\r\n \"gemini-2.5-flash-image-landscape\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\"\r\n },\r\n \"gemini-2.5-flash-image-portrait\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\"\r\n },\r\n\r\n # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro)\r\n \"gemini-3.0-pro-image-landscape\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\"\r\n },\r\n \"gemini-3.0-pro-image-portrait\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\"\r\n },\r\n \"gemini-3.0-pro-image-square\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_SQUARE\"\r\n },\r\n \"gemini-3.0-pro-image-four-three\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE\"\r\n },\r\n \"gemini-3.0-pro-image-three-four\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR\"\r\n },\r\n\r\n # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro) 2K 放大版\r\n \"gemini-3.0-pro-image-landscape-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n \"gemini-3.0-pro-image-portrait-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n \"gemini-3.0-pro-image-square-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_SQUARE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n \"gemini-3.0-pro-image-four-three-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n \"gemini-3.0-pro-image-three-four-2k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_2K\"\r\n },\r\n\r\n # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro) 4K 放大版\r\n \"gemini-3.0-pro-image-landscape-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n \"gemini-3.0-pro-image-portrait-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n \"gemini-3.0-pro-image-square-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_SQUARE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n \"gemini-3.0-pro-image-four-three-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n \"gemini-3.0-pro-image-three-four-4k\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"GEM_PIX_2\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT_THREE_FOUR\",\r\n \"upsample\": \"UPSAMPLE_IMAGE_RESOLUTION_4K\"\r\n },\r\n\r\n # 图片生成 - IMAGEN_3_5 (Imagen 4.0)\r\n \"imagen-4.0-generate-preview-landscape\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"IMAGEN_3_5\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_LANDSCAPE\"\r\n },\r\n \"imagen-4.0-generate-preview-portrait\": {\r\n \"type\": \"image\",\r\n \"model_name\": \"IMAGEN_3_5\",\r\n \"aspect_ratio\": \"IMAGE_ASPECT_RATIO_PORTRAIT\"\r\n },\r\n\r\n # ========== 文生视频 (T2V - Text to Video) ==========\r\n # 不支持上传图片,只使用文本提示词生成\r\n\r\n # veo_3_1_t2v_fast_portrait (竖屏)\r\n # 上游模型名: veo_3_1_t2v_fast_portrait\r\n \"veo_3_1_t2v_fast_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n # veo_3_1_t2v_fast_landscape (横屏)\r\n # 上游模型名: veo_3_1_t2v_fast\r\n \"veo_3_1_t2v_fast_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_2_1_fast_d_15_t2v (需要新增横竖屏)\r\n \"veo_2_1_fast_d_15_t2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_2_1_fast_d_15_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_2_1_fast_d_15_t2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_2_1_fast_d_15_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_2_0_t2v (需要新增横竖屏)\r\n \"veo_2_0_t2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_2_0_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_2_0_t2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_2_0_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_3_1_t2v_fast_ultra (横竖屏)\r\n \"veo_3_1_t2v_fast_portrait_ultra\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_3_1_t2v_fast_ultra\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_3_1_t2v_fast_ultra_relaxed (横竖屏)\r\n \"veo_3_1_t2v_fast_portrait_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_3_1_t2v_fast_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # veo_3_1_t2v (横竖屏)\r\n \"veo_3_1_t2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False\r\n },\r\n \"veo_3_1_t2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False\r\n },\r\n\r\n # ========== 首尾帧模型 (I2V - Image to Video) ==========\r\n # 支持1-2张图片:1张作为首帧,2张作为首尾帧\r\n\r\n # veo_3_1_i2v_s_fast_fl (需要新增横竖屏)\r\n \"veo_3_1_i2v_s_fast_portrait_fl\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_3_1_i2v_s_fast_fl\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_2_1_fast_d_15_i2v (需要新增横竖屏)\r\n \"veo_2_1_fast_d_15_i2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_2_1_fast_d_15_i2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_2_1_fast_d_15_i2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_2_1_fast_d_15_i2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_2_0_i2v (需要新增横竖屏)\r\n \"veo_2_0_i2v_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_2_0_i2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_2_0_i2v_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_2_0_i2v\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_3_1_i2v_s_fast_ultra (横竖屏)\r\n \"veo_3_1_i2v_s_fast_portrait_ultra_fl\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_3_1_i2v_s_fast_ultra_fl\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_3_1_i2v_s_fast_ultra_relaxed (需要新增横竖屏)\r\n \"veo_3_1_i2v_s_fast_portrait_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_3_1_i2v_s_fast_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # veo_3_1_i2v_s (需要新增横竖屏)\r\n \"veo_3_1_i2v_s_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n \"veo_3_1_i2v_s_landscape\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2\r\n },\r\n\r\n # ========== 多图生成 (R2V - Reference Images to Video) ==========\r\n # 支持多张图片,不限制数量\r\n\r\n # veo_3_1_r2v_fast (横竖屏)\r\n \"veo_3_1_r2v_fast_portrait\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n \"veo_3_1_r2v_fast\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n\r\n # veo_3_1_r2v_fast_ultra (横竖屏)\r\n \"veo_3_1_r2v_fast_portrait_ultra\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n \"veo_3_1_r2v_fast_ultra\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n\r\n # veo_3_1_r2v_fast_ultra_relaxed (横竖屏)\r\n \"veo_3_1_r2v_fast_portrait_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n \"veo_3_1_r2v_fast_ultra_relaxed\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_ultra_relaxed\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None # 不限制\r\n },\r\n\r\n # ========== 视频放大 (Video Upsampler) ==========\r\n # 仅 3.1 支持,需要先生成视频后再放大,可能需要 30 分钟\r\n\r\n # T2V 4K 放大版\r\n \"veo_3_1_t2v_fast_portrait_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_t2v_fast_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_t2v_fast_portrait_ultra_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_t2v_fast_ultra_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n\r\n # T2V 1080P 放大版\r\n \"veo_3_1_t2v_fast_portrait_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_t2v_fast_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_t2v_fast_portrait_ultra_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_t2v_fast_ultra_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"t2v\",\r\n \"model_key\": \"veo_3_1_t2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": False,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n\r\n # I2V 4K 放大版\r\n \"veo_3_1_i2v_s_fast_portrait_ultra_fl_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_i2v_s_fast_ultra_fl_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n\r\n # I2V 1080P 放大版\r\n \"veo_3_1_i2v_s_fast_portrait_ultra_fl_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_portrait_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_i2v_s_fast_ultra_fl_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"i2v\",\r\n \"model_key\": \"veo_3_1_i2v_s_fast_ultra_fl\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 1,\r\n \"max_images\": 2,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n\r\n # R2V 4K 放大版\r\n \"veo_3_1_r2v_fast_portrait_ultra_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n \"veo_3_1_r2v_fast_ultra_4k\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n },\r\n\r\n # R2V 1080P 放大版\r\n \"veo_3_1_r2v_fast_portrait_ultra_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_portrait_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_PORTRAIT\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n },\r\n \"veo_3_1_r2v_fast_ultra_1080p\": {\r\n \"type\": \"video\",\r\n \"video_type\": \"r2v\",\r\n \"model_key\": \"veo_3_1_r2v_fast_ultra\",\r\n \"aspect_ratio\": \"VIDEO_ASPECT_RATIO_LANDSCAPE\",\r\n \"supports_images\": True,\r\n \"min_images\": 0,\r\n \"max_images\": None,\r\n \"upsample\": {\"resolution\": \"VIDEO_RESOLUTION_1080P\", \"model_key\": \"veo_3_1_upsampler_1080p\"}\r\n }\r\n}\r\n\r\n\r\nclass GenerationHandler:\r\n \"\"\"统一生成处理器\"\"\"\r\n\r\n def __init__(self, flow_client, token_manager, load_balancer, db, concurrency_manager, proxy_manager):\r\n self.flow_client = flow_client\r\n self.token_manager = token_manager\r\n self.load_balancer = load_balancer\r\n self.db = db\r\n self.concurrency_manager = concurrency_manager\r\n self.file_cache = FileCache(\r\n cache_dir=\"tmp\",\r\n default_timeout=config.cache_timeout,\r\n proxy_manager=proxy_manager\r\n )\r\n\r\n async def check_token_availability(self, is_image: bool, is_video: bool) -> bool:\r\n \"\"\"检查Token可用性\r\n\r\n Args:\r\n is_image: 是否检查图片生成Token\r\n is_video: 是否检查视频生成Token\r\n\r\n Returns:\r\n True表示有可用Token, False表示无可用Token\r\n \"\"\"\r\n token_obj = await self.load_balancer.select_token(\r\n for_image_generation=is_image,\r\n for_video_generation=is_video\r\n )\r\n return token_obj is not None\r\n\r\n async def handle_generation(\r\n self,\r\n model: str,\r\n prompt: str,\r\n images: Optional[List[bytes]] = None,\r\n stream: bool = False\r\n ) -> AsyncGenerator:\r\n \"\"\"统一生成入口\r\n\r\n Args:\r\n model: 模型名称\r\n prompt: 提示词\r\n images: 图片列表 (bytes格式)\r\n stream: 是否流式输出\r\n \"\"\"\r\n start_time = time.time()\r\n token = None\r\n\r\n # 1. 验证模型\r\n if model not in MODEL_CONFIG:\r\n error_msg = f\"不支持的模型: {model}\"\r\n debug_logger.log_error(error_msg)\r\n yield self._create_error_response(error_msg)\r\n return\r\n\r\n model_config = MODEL_CONFIG[model]\r\n generation_type = model_config[\"type\"]\r\n debug_logger.log_info(f\"[GENERATION] 开始生成 - 模型: {model}, 类型: {generation_type}, Prompt: {prompt[:50]}...\")\r\n\r\n # 非流式��式: 只检查可用性\r\n if not stream:\r\n is_image = (generation_type == \"image\")\r\n is_video = (generation_type == \"video\")\r\n available = await self.check_token_availability(is_image, is_video)\r\n\r\n if available:\r\n if is_image:\r\n message = \"所有Token可用于图片生成。请启用流式模式使用生成功能。\"\r\n else:\r\n message = \"所有Token可用于视频生成。请启用流式模式使用生成功能。\"\r\n else:\r\n if is_image:\r\n message = \"没有可用的Token进行图片生成\"\r\n else:\r\n message = \"没有可用的Token进行视频生成\"\r\n\r\n yield self._create_completion_response(message, is_availability_check=True)\r\n return\r\n\r\n # 向用户展示开始信息\r\n if stream:\r\n yield self._create_stream_chunk(\r\n f\"✨ {'视频' if generation_type == 'video' else '图片'}生成任务已启动\\n\",\r\n role=\"assistant\"\r\n )\r\n\r\n # 2. 选择Token\r\n debug_logger.log_info(f\"[GENERATION] 正在选择可用Token...\")\r\n\r\n if generation_type == \"image\":\r\n token = await self.load_balancer.select_token(for_image_generation=True, model=model)\r\n else:\r\n token = await self.load_balancer.select_token(for_video_generation=True, model=model)\r\n\r\n if not token:\r\n error_msg = self._get_no_token_error_message(generation_type)\r\n debug_logger.log_error(f\"[GENERATION] {error_msg}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"❌ {error_msg}\\n\")\r\n yield self._create_error_response(error_msg)\r\n return\r\n\r\n debug_logger.log_info(f\"[GENERATION] 已选择Token: {token.id} ({token.email})\")\r\n\r\n try:\r\n # 3. 确保AT有效\r\n debug_logger.log_info(f\"[GENERATION] 检查Token AT有效性...\")\r\n if stream:\r\n yield self._create_stream_chunk(\"初始化生成环境...\\n\")\r\n\r\n if not await self.token_manager.is_at_valid(token.id):\r\n error_msg = \"Token AT无效或刷新失败\"\r\n debug_logger.log_error(f\"[GENERATION] {error_msg}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"❌ {error_msg}\\n\")\r\n yield self._create_error_response(error_msg)\r\n return\r\n\r\n # 重新获取token (AT可能已刷新)\r\n token = await self.token_manager.get_token(token.id)\r\n\r\n # 4. 确保Project存在\r\n debug_logger.log_info(f\"[GENERATION] 检查/创建Project...\")\r\n\r\n project_id = await self.token_manager.ensure_project_exists(token.id)\r\n debug_logger.log_info(f\"[GENERATION] Project ID: {project_id}\")\r\n\r\n # 5. 根据类型处理\r\n if generation_type == \"image\":\r\n debug_logger.log_info(f\"[GENERATION] 开始图片生成流程...\")\r\n async for chunk in self._handle_image_generation(\r\n token, project_id, model_config, prompt, images, stream\r\n ):\r\n yield chunk\r\n else: # video\r\n debug_logger.log_info(f\"[GENERATION] 开始视频生成流程...\")\r\n async for chunk in self._handle_video_generation(\r\n token, project_id, model_config, prompt, images, stream\r\n ):\r\n yield chunk\r\n\r\n # 6. 记录使用\r\n is_video = (generation_type == \"video\")\r\n await self.token_manager.record_usage(token.id, is_video=is_video)\r\n\r\n # 重置错误计数 (请求成功时清空连续错误计数)\r\n await self.token_manager.record_success(token.id)\r\n\r\n debug_logger.log_info(f\"[GENERATION] ✅ 生成成功完成\")\r\n\r\n # 7. 记录成功日志\r\n duration = time.time() - start_time\r\n\r\n # 构建响应数据,包含生成的URL\r\n response_data = {\r\n \"status\": \"success\",\r\n \"model\": model,\r\n \"prompt\": prompt[:100]\r\n }\r\n\r\n # 添加生成的URL(如果有)\r\n if hasattr(self, '_last_generated_url') and self._last_generated_url:\r\n response_data[\"url\"] = self._last_generated_url\r\n # 清除临时存储\r\n self._last_generated_url = None\r\n\r\n await self._log_request(\r\n token.id,\r\n f\"generate_{generation_type}\",\r\n {\"model\": model, \"prompt\": prompt[:100], \"has_images\": images is not None and len(images) > 0},\r\n response_data,\r\n 200,\r\n duration\r\n )\r\n\r\n except Exception as e:\r\n error_msg = f\"生成失败: {str(e)}\"\r\n debug_logger.log_error(f\"[GENERATION] ❌ {error_msg}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"❌ {error_msg}\\n\")\r\n if token:\r\n # 记录错误(所有错误统一处理,不再特殊处理429)\r\n await self.token_manager.record_error(token.id)\r\n yield self._create_error_response(error_msg)\r\n\r\n # 记录失败日志\r\n duration = time.time() - start_time\r\n await self._log_request(\r\n token.id if token else None,\r\n f\"generate_{generation_type if model_config else 'unknown'}\",\r\n {\"model\": model, \"prompt\": prompt[:100], \"has_images\": images is not None and len(images) > 0},\r\n {\"error\": error_msg},\r\n 500,\r\n duration\r\n )\r\n\r\n def _get_no_token_error_message(self, generation_type: str) -> str:\r\n \"\"\"获取无可用Token时的详细错误信息\"\"\"\r\n if generation_type == \"image\":\r\n return \"没有可用的Token进行图片生成。所有Token都处于禁用、冷却、锁定或已过期状态。\"\r\n else:\r\n return \"没有可用的Token进行视频生成。所有Token都处于禁用、冷却、配额耗尽或已过期状态。\"\r\n\r\n async def _handle_image_generation(\r\n self,\r\n token,\r\n project_id: str,\r\n model_config: dict,\r\n prompt: str,\r\n images: Optional[List[bytes]],\r\n stream: bool\r\n ) -> AsyncGenerator:\r\n \"\"\"处理图片生成 (同步返回)\"\"\"\r\n\r\n # 获取并发槽位\r\n if self.concurrency_manager:\r\n if not await self.concurrency_manager.acquire_image(token.id):\r\n yield self._create_error_response(\"图片并发限制已达上限\")\r\n return\r\n\r\n try:\r\n # 上传图片 (如果有)\r\n image_inputs = []\r\n if images and len(images) > 0:\r\n if stream:\r\n yield self._create_stream_chunk(f\"上传 {len(images)} 张参考图片...\\n\")\r\n\r\n # 支持多图输入\r\n for idx, image_bytes in enumerate(images):\r\n media_id = await self.flow_client.upload_image(\r\n token.at,\r\n image_bytes,\r\n model_config[\"aspect_ratio\"]\r\n )\r\n image_inputs.append({\r\n \"name\": media_id,\r\n \"imageInputType\": \"IMAGE_INPUT_TYPE_REFERENCE\"\r\n })\r\n if stream:\r\n yield self._create_stream_chunk(f\"已上传第 {idx + 1}/{len(images)} 张图片\\n\")\r\n\r\n # 调用生成API\r\n if stream:\r\n yield self._create_stream_chunk(\"正在生成图片...\\n\")\r\n\r\n result = await self.flow_client.generate_image(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_name=model_config[\"model_name\"],\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n image_inputs=image_inputs\r\n )\r\n\r\n # 提取URL和mediaId\r\n media = result.get(\"media\", [])\r\n if not media:\r\n yield self._create_error_response(\"生成结果为空\")\r\n return\r\n\r\n image_url = media[0][\"image\"][\"generatedImage\"][\"fifeUrl\"]\r\n media_id = media[0].get(\"name\") # 用于 upsample\r\n\r\n # 检查是否需要 upsample\r\n upsample_resolution = model_config.get(\"upsample\")\r\n if upsample_resolution and media_id:\r\n resolution_name = \"4K\" if \"4K\" in upsample_resolution else \"2K\"\r\n if stream:\r\n yield self._create_stream_chunk(f\"正在放大图片到 {resolution_name}...\\n\")\r\n\r\n # 4K/2K 图片重试逻辑 - 最多重试3次\r\n max_retries = 3\r\n for retry_attempt in range(max_retries):\r\n try:\r\n # 调用 upsample API\r\n encoded_image = await self.flow_client.upsample_image(\r\n at=token.at,\r\n project_id=project_id,\r\n media_id=media_id,\r\n target_resolution=upsample_resolution\r\n )\r\n\r\n if encoded_image:\r\n debug_logger.log_info(f\"[UPSAMPLE] 图片已放大到 {resolution_name}\")\r\n\r\n if stream:\r\n yield self._create_stream_chunk(f\"✅ 图片已放大到 {resolution_name}\\n\")\r\n\r\n # 缓存放大后的图片 (如果启用)\r\n # 日志统一记录原图URL (放大后的base64数据太大,不适合存储)\r\n self._last_generated_url = image_url\r\n\r\n if config.cache_enabled:\r\n try:\r\n if stream:\r\n yield self._create_stream_chunk(f\"缓存 {resolution_name} 图片中...\\n\")\r\n cached_filename = await self.file_cache.cache_base64_image(encoded_image, resolution_name)\r\n local_url = f\"{self._get_base_url()}/tmp/{cached_filename}\"\r\n if stream:\r\n yield self._create_stream_chunk(f\"✅ {resolution_name} 图片缓存成功\\n\")\r\n yield self._create_stream_chunk(\r\n f\"![Generated Image]({local_url})\",\r\n finish_reason=\"stop\"\r\n )\r\n else:\r\n yield self._create_completion_response(\r\n local_url,\r\n media_type=\"image\"\r\n )\r\n return\r\n except Exception as e:\r\n debug_logger.log_error(f\"Failed to cache {resolution_name} image: {str(e)}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 缓存失败: {str(e)},返回 base64...\\n\")\r\n\r\n # 缓存未启用或缓存失败,返回 base64 格式\r\n base64_url = f\"data:image/jpeg;base64,{encoded_image}\"\r\n if stream:\r\n yield self._create_stream_chunk(\r\n f\"![Generated Image]({base64_url})\",\r\n finish_reason=\"stop\"\r\n )\r\n else:\r\n yield self._create_completion_response(\r\n base64_url,\r\n media_type=\"image\"\r\n )\r\n return\r\n else:\r\n debug_logger.log_warning(\"[UPSAMPLE] 返回结果为空\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 放大失败,返回原图...\\n\")\r\n break # 空结果不重试\r\n\r\n except Exception as e:\r\n error_str = str(e)\r\n debug_logger.log_error(f\"[UPSAMPLE] 放大失败 (尝试 {retry_attempt + 1}/{max_retries}): {error_str}\")\r\n \r\n # 检查是否是可重试错误(403、reCAPTCHA、超时等)\r\n retry_reason = self.flow_client._get_retry_reason(error_str)\r\n if retry_reason and retry_attempt < max_retries - 1:\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 放大遇到{retry_reason},正在重试 ({retry_attempt + 2}/{max_retries})...\\n\")\r\n # 等待一小段时间后重试\r\n await asyncio.sleep(1)\r\n continue\r\n else:\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 放大失败: {error_str},返回原图...\\n\")\r\n break\r\n\r\n # 缓存图片 (如果启用)\r\n local_url = image_url\r\n if config.cache_enabled:\r\n try:\r\n if stream:\r\n yield self._create_stream_chunk(\"缓存图片中...\\n\")\r\n cached_filename = await self.file_cache.download_and_cache(image_url, \"image\")\r\n local_url = f\"{self._get_base_url()}/tmp/{cached_filename}\"\r\n if stream:\r\n yield self._create_stream_chunk(\"✅ 图片缓存成功,准备返回缓存地址...\\n\")\r\n except Exception as e:\r\n debug_logger.log_error(f\"Failed to cache image: {str(e)}\")\r\n # 缓存失败不影响结果返回,使用原始URL\r\n local_url = image_url\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 缓存失败: {str(e)}\\n正在返回源链接...\\n\")\r\n else:\r\n if stream:\r\n yield self._create_stream_chunk(\"缓存已关闭,正在返回源链接...\\n\")\r\n\r\n # 返回结果\r\n # 存储URL用于日志记录\r\n self._last_generated_url = local_url\r\n\r\n if stream:\r\n yield self._create_stream_chunk(\r\n f\"![Generated Image]({local_url})\",\r\n finish_reason=\"stop\"\r\n )\r\n else:\r\n yield self._create_completion_response(\r\n local_url, # 直接传URL,让方法内部格式化\r\n media_type=\"image\"\r\n )\r\n\r\n finally:\r\n # 释放并发槽位\r\n if self.concurrency_manager:\r\n await self.concurrency_manager.release_image(token.id)\r\n\r\n async def _handle_video_generation(\r\n self,\r\n token,\r\n project_id: str,\r\n model_config: dict,\r\n prompt: str,\r\n images: Optional[List[bytes]],\r\n stream: bool\r\n ) -> AsyncGenerator:\r\n \"\"\"处理视频生成 (异步轮询)\"\"\"\r\n\r\n # 获取并发槽位\r\n if self.concurrency_manager:\r\n if not await self.concurrency_manager.acquire_video(token.id):\r\n yield self._create_error_response(\"视频并发限制已达上限\")\r\n return\r\n\r\n try:\r\n # 获取模型类型和配置\r\n video_type = model_config.get(\"video_type\")\r\n supports_images = model_config.get(\"supports_images\", False)\r\n min_images = model_config.get(\"min_images\", 0)\r\n max_images = model_config.get(\"max_images\", 0)\r\n\r\n # 根据账号tier自动调整模型 key\r\n model_key = model_config[\"model_key\"]\r\n user_tier = token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n\r\n # TIER_TWO 账号需要使用 ultra 版本的模型\r\n if user_tier == \"PAYGATE_TIER_TWO\":\r\n # 如果模型 key 不包含 ultra,自动添加\r\n if \"ultra\" not in model_key:\r\n # veo_3_1_i2v_s_fast_fl -> veo_3_1_i2v_s_fast_ultra_fl\r\n # veo_3_1_i2v_s_fast_portrait_fl -> veo_3_1_i2v_s_fast_portrait_ultra_fl\r\n # veo_3_1_t2v_fast -> veo_3_1_t2v_fast_ultra\r\n # veo_3_1_t2v_fast_portrait -> veo_3_1_t2v_fast_portrait_ultra\r\n # veo_3_0_r2v_fast -> veo_3_0_r2v_fast_ultra\r\n if \"_fl\" in model_key:\r\n model_key = model_key.replace(\"_fl\", \"_ultra_fl\")\r\n else:\r\n # 直接在末尾添加 _ultra\r\n model_key = model_key + \"_ultra\"\r\n \r\n if stream:\r\n yield self._create_stream_chunk(f\"TIER_TWO 账号自动切换到 ultra 模型: {model_key}\\n\")\r\n debug_logger.log_info(f\"[VIDEO] TIER_TWO 账号,模型自动调整: {model_config['model_key']} -> {model_key}\")\r\n\r\n # TIER_ONE 账号需要使用非 ultra 版本\r\n elif user_tier == \"PAYGATE_TIER_ONE\":\r\n # 如果模型 key 包含 ultra,需要移除(避免用户误用)\r\n if \"ultra\" in model_key:\r\n # veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_fl\r\n # veo_3_1_t2v_fast_ultra -> veo_3_1_t2v_fast\r\n model_key = model_key.replace(\"_ultra_fl\", \"_fl\").replace(\"_ultra\", \"\")\r\n \r\n if stream:\r\n yield self._create_stream_chunk(f\"TIER_ONE 账号自动切换到标准模型: {model_key}\\n\")\r\n debug_logger.log_info(f\"[VIDEO] TIER_ONE 账号,模型自动调整: {model_config['model_key']} -> {model_key}\")\r\n\r\n # 更新 model_config 中的 model_key\r\n model_config = dict(model_config) # 创建副本避免修改原配置\r\n model_config[\"model_key\"] = model_key\r\n\r\n # 图片数量\r\n image_count = len(images) if images else 0\r\n\r\n # ========== 验证和处理图片 ==========\r\n\r\n # T2V: 文生视频 - 不支持图片\r\n if video_type == \"t2v\":\r\n if image_count > 0:\r\n if stream:\r\n yield self._create_stream_chunk(\"⚠️ 文生视频模型不支持上传图片,将忽略图片仅使用文本提示词生成\\n\")\r\n debug_logger.log_warning(f\"[T2V] 模型 {model_config['model_key']} 不支持图片,已忽略 {image_count} 张图片\")\r\n images = None # 清空图片\r\n image_count = 0\r\n\r\n # I2V: 首尾帧模型 - 需要1-2张图片\r\n elif video_type == \"i2v\":\r\n if image_count < min_images or image_count > max_images:\r\n error_msg = f\"❌ 首尾帧模型需要 {min_images}-{max_images} 张图片,当前提供了 {image_count} 张\"\r\n if stream:\r\n yield self._create_stream_chunk(f\"{error_msg}\\n\")\r\n yield self._create_error_response(error_msg)\r\n return\r\n\r\n # R2V: 多图生成 - 支持多张图片,不限制数量\r\n elif video_type == \"r2v\":\r\n # 不再限制最大图片数量\r\n pass\r\n\r\n # ========== 上传图片 ==========\r\n start_media_id = None\r\n end_media_id = None\r\n reference_images = []\r\n\r\n # I2V: 首尾帧处理\r\n if video_type == \"i2v\" and images:\r\n if image_count == 1:\r\n # 只有1张图: 仅作为首帧\r\n if stream:\r\n yield self._create_stream_chunk(\"上传首帧图片...\\n\")\r\n start_media_id = await self.flow_client.upload_image(\r\n token.at, images[0], model_config[\"aspect_ratio\"]\r\n )\r\n debug_logger.log_info(f\"[I2V] 仅上传首帧: {start_media_id}\")\r\n\r\n elif image_count == 2:\r\n # 2张图: 首帧+尾帧\r\n if stream:\r\n yield self._create_stream_chunk(\"上传首帧和尾帧图片...\\n\")\r\n start_media_id = await self.flow_client.upload_image(\r\n token.at, images[0], model_config[\"aspect_ratio\"]\r\n )\r\n end_media_id = await self.flow_client.upload_image(\r\n token.at, images[1], model_config[\"aspect_ratio\"]\r\n )\r\n debug_logger.log_info(f\"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}\")\r\n\r\n # R2V: 多图处理\r\n elif video_type == \"r2v\" and images:\r\n if stream:\r\n yield self._create_stream_chunk(f\"上传 {image_count} 张参考图片...\\n\")\r\n\r\n for idx, img in enumerate(images): # 上传所有图片,不限制数量\r\n media_id = await self.flow_client.upload_image(\r\n token.at, img, model_config[\"aspect_ratio\"]\r\n )\r\n reference_images.append({\r\n \"imageUsageType\": \"IMAGE_USAGE_TYPE_ASSET\",\r\n \"mediaId\": media_id\r\n })\r\n debug_logger.log_info(f\"[R2V] 上传了 {len(reference_images)} 张参考图片\")\r\n\r\n # ========== 调用生成API ==========\r\n if stream:\r\n yield self._create_stream_chunk(\"提交视频生成任务...\\n\")\r\n\r\n # I2V: 首尾帧生成\r\n if video_type == \"i2v\" and start_media_id:\r\n if end_media_id:\r\n # 有首尾帧\r\n result = await self.flow_client.generate_video_start_end(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_key=model_config[\"model_key\"],\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n start_media_id=start_media_id,\r\n end_media_id=end_media_id,\r\n user_paygate_tier=token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n )\r\n else:\r\n # 只有首帧 - 需要去掉 model_key 中的 _fl\r\n # 情况1: _fl_ 在中间 (如 veo_3_1_i2v_s_fast_fl_ultra_relaxed -> veo_3_1_i2v_s_fast_ultra_relaxed)\r\n # 情况2: _fl 在结尾 (如 veo_3_1_i2v_s_fast_ultra_fl -> veo_3_1_i2v_s_fast_ultra)\r\n actual_model_key = model_config[\"model_key\"].replace(\"_fl_\", \"_\")\r\n if actual_model_key.endswith(\"_fl\"):\r\n actual_model_key = actual_model_key[:-3]\r\n debug_logger.log_info(f\"[I2V] 单帧模式,model_key: {model_config['model_key']} -> {actual_model_key}\")\r\n result = await self.flow_client.generate_video_start_image(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_key=actual_model_key,\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n start_media_id=start_media_id,\r\n user_paygate_tier=token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n )\r\n\r\n # R2V: 多图生成\r\n elif video_type == \"r2v\" and reference_images:\r\n result = await self.flow_client.generate_video_reference_images(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_key=model_config[\"model_key\"],\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n reference_images=reference_images,\r\n user_paygate_tier=token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n )\r\n\r\n # T2V 或 R2V无图: 纯文本生成\r\n else:\r\n result = await self.flow_client.generate_video_text(\r\n at=token.at,\r\n project_id=project_id,\r\n prompt=prompt,\r\n model_key=model_config[\"model_key\"],\r\n aspect_ratio=model_config[\"aspect_ratio\"],\r\n user_paygate_tier=token.user_paygate_tier or \"PAYGATE_TIER_ONE\"\r\n )\r\n\r\n # 获取task_id和operations\r\n operations = result.get(\"operations\", [])\r\n if not operations:\r\n yield self._create_error_response(\"生成任务创建失败\")\r\n return\r\n\r\n operation = operations[0]\r\n task_id = operation[\"operation\"][\"name\"]\r\n scene_id = operation.get(\"sceneId\")\r\n\r\n # 保存Task到数据库\r\n task = Task(\r\n task_id=task_id,\r\n token_id=token.id,\r\n model=model_config[\"model_key\"],\r\n prompt=prompt,\r\n status=\"processing\",\r\n scene_id=scene_id\r\n )\r\n await self.db.create_task(task)\r\n\r\n # 轮询结果\r\n if stream:\r\n yield self._create_stream_chunk(f\"视频生成中...\\n\")\r\n\r\n # 检查是否需要放大\r\n upsample_config = model_config.get(\"upsample\")\r\n\r\n async for chunk in self._poll_video_result(token, project_id, operations, stream, upsample_config):\r\n yield chunk\r\n\r\n finally:\r\n # 释放并发槽位\r\n if self.concurrency_manager:\r\n await self.concurrency_manager.release_video(token.id)\r\n\r\n async def _poll_video_result(\r\n self,\r\n token,\r\n project_id: str,\r\n operations: List[Dict],\r\n stream: bool,\r\n upsample_config: Optional[Dict] = None\r\n ) -> AsyncGenerator:\r\n \"\"\"轮询视频生成结果\r\n \r\n Args:\r\n upsample_config: 放大配置 {\"resolution\": \"VIDEO_RESOLUTION_4K\", \"model_key\": \"veo_3_1_upsampler_4k\"}\r\n \"\"\"\r\n\r\n max_attempts = config.max_poll_attempts\r\n poll_interval = config.poll_interval\r\n \r\n # 如果需要放大,轮询次数加倍(放大可能需要 30 分钟)\r\n if upsample_config:\r\n max_attempts = max_attempts * 3 # 放大需要更长时间\r\n\r\n for attempt in range(max_attempts):\r\n await asyncio.sleep(poll_interval)\r\n\r\n try:\r\n result = await self.flow_client.check_video_status(token.at, operations)\r\n checked_operations = result.get(\"operations\", [])\r\n\r\n if not checked_operations:\r\n continue\r\n\r\n operation = checked_operations[0]\r\n status = operation.get(\"status\")\r\n\r\n # 状态更新 - 每20秒报告一次 (poll_interval=3秒, 20秒约7次轮询)\r\n progress_update_interval = 7 # 每7次轮询 = 21秒\r\n if stream and attempt % progress_update_interval == 0: # 每20秒报告一次\r\n progress = min(int((attempt / max_attempts) * 100), 95)\r\n yield self._create_stream_chunk(f\"生成进度: {progress}%\\n\")\r\n\r\n # 检查状态\r\n if status == \"MEDIA_GENERATION_STATUS_SUCCESSFUL\":\r\n # 成功\r\n metadata = operation[\"operation\"].get(\"metadata\", {})\r\n video_info = metadata.get(\"video\", {})\r\n video_url = video_info.get(\"fifeUrl\")\r\n video_media_id = video_info.get(\"mediaGenerationId\")\r\n aspect_ratio = video_info.get(\"aspectRatio\", \"VIDEO_ASPECT_RATIO_LANDSCAPE\")\r\n\r\n if not video_url:\r\n yield self._create_error_response(\"视频URL为空\")\r\n return\r\n\r\n # ========== 视频放大处理 ==========\r\n if upsample_config and video_media_id:\r\n if stream:\r\n resolution_name = \"4K\" if \"4K\" in upsample_config[\"resolution\"] else \"1080P\"\r\n yield self._create_stream_chunk(f\"\\n视频生成完成,开始 {resolution_name} 放大处理...(可能需要 30 分钟)\\n\")\r\n \r\n try:\r\n # 提交放大任务\r\n upsample_result = await self.flow_client.upsample_video(\r\n at=token.at,\r\n project_id=project_id,\r\n video_media_id=video_media_id,\r\n aspect_ratio=aspect_ratio,\r\n resolution=upsample_config[\"resolution\"],\r\n model_key=upsample_config[\"model_key\"]\r\n )\r\n \r\n upsample_operations = upsample_result.get(\"operations\", [])\r\n if upsample_operations:\r\n if stream:\r\n yield self._create_stream_chunk(\"放大任务已提交,继续轮询...\\n\")\r\n \r\n # 递归轮询放大结果(不再放大)\r\n async for chunk in self._poll_video_result(\r\n token, project_id, upsample_operations, stream, None\r\n ):\r\n yield chunk\r\n return\r\n else:\r\n if stream:\r\n yield self._create_stream_chunk(\"⚠️ 放大任务创建失败,返回原始视频\\n\")\r\n except Exception as e:\r\n debug_logger.log_error(f\"Video upsample failed: {str(e)}\")\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 放大失败: {str(e)},返回原始视频\\n\")\r\n\r\n # 缓存视频 (如果启用)\r\n local_url = video_url\r\n if config.cache_enabled:\r\n try:\r\n if stream:\r\n yield self._create_stream_chunk(\"正在缓存视频文件...\\n\")\r\n cached_filename = await self.file_cache.download_and_cache(video_url, \"video\")\r\n local_url = f\"{self._get_base_url()}/tmp/{cached_filename}\"\r\n if stream:\r\n yield self._create_stream_chunk(\"✅ 视频缓存成功,准备返回缓存地址...\\n\")\r\n except Exception as e:\r\n debug_logger.log_error(f\"Failed to cache video: {str(e)}\")\r\n # 缓存失败不影响结果返回,使用原始URL\r\n local_url = video_url\r\n if stream:\r\n yield self._create_stream_chunk(f\"⚠️ 缓存失败: {str(e)}\\n正在返回源链接...\\n\")\r\n else:\r\n if stream:\r\n yield self._create_stream_chunk(\"缓存已关闭,正在返回源链接...\\n\")\r\n\r\n # 更新数据库\r\n task_id = operation[\"operation\"][\"name\"]\r\n await self.db.update_task(\r\n task_id,\r\n status=\"completed\",\r\n progress=100,\r\n result_urls=[local_url],\r\n completed_at=time.time()\r\n )\r\n\r\n # 存储URL用于日志记录\r\n self._last_generated_url = local_url\r\n\r\n # 返回结果\r\n if stream:\r\n yield self._create_stream_chunk(\r\n f\"<video src='{local_url}' controls style='max-width:100%'></video>\",\r\n finish_reason=\"stop\"\r\n )\r\n else:\r\n yield self._create_completion_response(\r\n local_url, # 直接传URL,让方法内部格式化\r\n media_type=\"video\"\r\n )\r\n return\r\n\r\n elif status == \"MEDIA_GENERATION_STATUS_FAILED\":\r\n # 生成失败 - 提取错误信息\r\n error_info = operation.get(\"operation\", {}).get(\"error\", {})\r\n error_code = error_info.get(\"code\", \"unknown\")\r\n error_message = error_info.get(\"message\", \"未知错误\")\r\n \r\n # 更新数据库任务状态\r\n task_id = operation[\"operation\"][\"name\"]\r\n await self.db.update_task(\r\n task_id,\r\n status=\"failed\",\r\n error_message=f\"{error_message} (code: {error_code})\",\r\n completed_at=time.time()\r\n )\r\n \r\n # 返回友好的错误消息,提示用户重试\r\n friendly_error = f\"视频生成失败: {error_message},请重试\"\r\n if stream:\r\n yield self._create_stream_chunk(f\"❌ {friendly_error}\\n\")\r\n yield self._create_error_response(friendly_error)\r\n return\r\n\r\n elif status.startswith(\"MEDIA_GENERATION_STATUS_ERROR\"):\r\n # 其他错误状态\r\n yield self._create_error_response(f\"视频生成失败: {status}\")\r\n return\r\n\r\n except Exception as e:\r\n debug_logger.log_error(f\"Poll error: {str(e)}\")\r\n continue\r\n\r\n # 超时\r\n yield self._create_error_response(f\"视频生成超时 (已轮询{max_attempts}次)\")\r\n\r\n # ========== 响应格式化 ==========\r\n\r\n def _create_stream_chunk(self, content: str, role: str = None, finish_reason: str = None) -> str:\r\n \"\"\"创建流式响应chunk\"\"\"\r\n import json\r\n import time\r\n\r\n chunk = {\r\n \"id\": f\"chatcmpl-{int(time.time())}\",\r\n \"object\": \"chat.completion.chunk\",\r\n \"created\": int(time.time()),\r\n \"model\": \"flow2api\",\r\n \"choices\": [{\r\n \"index\": 0,\r\n \"delta\": {},\r\n \"finish_reason\": finish_reason\r\n }]\r\n }\r\n\r\n if role:\r\n chunk[\"choices\"][0][\"delta\"][\"role\"] = role\r\n\r\n if finish_reason:\r\n chunk[\"choices\"][0][\"delta\"][\"content\"] = content\r\n else:\r\n chunk[\"choices\"][0][\"delta\"][\"reasoning_content\"] = content\r\n\r\n return f\"data: {json.dumps(chunk, ensure_ascii=False)}\\n\\n\"\r\n\r\n def _create_completion_response(self, content: str, media_type: str = \"image\", is_availability_check: bool = False) -> str:\r\n \"\"\"创建非流式响应\r\n\r\n Args:\r\n content: 媒体URL或纯文本消息\r\n media_type: 媒体类型 (\"image\" 或 \"video\")\r\n is_availability_check: 是否为可用性检查响应 (纯文本消息)\r\n\r\n Returns:\r\n JSON格式的响应\r\n \"\"\"\r\n import json\r\n import time\r\n\r\n # 可用性检查: 返回纯文本消息\r\n if is_availability_check:\r\n formatted_content = content\r\n else:\r\n # 媒体生成: 根据媒体类型格式化内容为Markdown\r\n if media_type == \"video\":\r\n formatted_content = f\"```html\\n<video src='{content}' controls></video>\\n```\"\r\n else: # image\r\n formatted_content = f\"![Generated Image]({content})\"\r\n\r\n response = {\r\n \"id\": f\"chatcmpl-{int(time.time())}\",\r\n \"object\": \"chat.completion\",\r\n \"created\": int(time.time()),\r\n \"model\": \"flow2api\",\r\n \"choices\": [{\r\n \"index\": 0,\r\n \"message\": {\r\n \"role\": \"assistant\",\r\n \"content\": formatted_content\r\n },\r\n \"finish_reason\": \"stop\"\r\n }]\r\n }\r\n\r\n return json.dumps(response, ensure_ascii=False)\r\n\r\n def _create_error_response(self, error_message: str) -> str:\r\n \"\"\"创建错误响应\"\"\"\r\n import json\r\n\r\n error = {\r\n \"error\": {\r\n \"message\": error_message,\r\n \"type\": \"invalid_request_error\",\r\n \"code\": \"generation_failed\"\r\n }\r\n }\r\n\r\n return json.dumps(error, ensure_ascii=False)\r\n\r\n def _get_base_url(self) -> str:\r\n \"\"\"获取基础URL用于缓存文件访问\"\"\"\r\n # 优先使用配置的cache_base_url\r\n if config.cache_base_url:\r\n return config.cache_base_url\r\n # 否则使用服务器地址\r\n return f\"http://{config.server_host}:{config.server_port}\"\r\n\r\n async def _log_request(\r\n self,\r\n token_id: Optional[int],\r\n operation: str,\r\n request_data: Dict[str, Any],\r\n response_data: Dict[str, Any],\r\n status_code: int,\r\n duration: float\r\n ):\r\n \"\"\"记录请求到数据库\"\"\"\r\n try:\r\n log = RequestLog(\r\n token_id=token_id,\r\n operation=operation,\r\n request_body=json.dumps(request_data, ensure_ascii=False),\r\n response_body=json.dumps(response_data, ensure_ascii=False),\r\n status_code=status_code,\r\n duration=duration\r\n )\r\n await self.db.add_request_log(log)\r\n except Exception as e:\r\n # 日志记录失败不影响主流程\r\n debug_logger.log_error(f\"Failed to log request: {e}\")\r\n"
16
+ }
17
+ ]
18
+ }
src/services/browser_captcha.py CHANGED
@@ -3,6 +3,8 @@
3
  支持:自动刷新 Session Token、外部触发指纹切换、死磕重试
4
  """
5
  import os
 
 
6
  # 修复 Windows 上 patchright 的 asyncio 兼容性问题
7
  os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0")
8
 
@@ -15,11 +17,160 @@ from typing import Optional, Dict
15
  from datetime import datetime
16
  from urllib.parse import urlparse, unquote
17
 
18
- from patchright.async_api import async_playwright, Route, BrowserContext
19
-
20
  from ..core.logger import debug_logger
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  # 配置
24
  LABS_URL = "https://labs.google/fx/tools/flow"
25
 
@@ -405,6 +556,19 @@ class BrowserCaptchaService:
405
  await cls._instance._load_browser_count()
406
  return cls._instance
407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  async def _load_browser_count(self):
409
  """从数据库加载浏览器数量配置"""
410
  if self.db:
@@ -471,6 +635,9 @@ class BrowserCaptchaService:
471
  Returns:
472
  (token, browser_id) 元组,调用方失败时用 browser_id 调用 report_error
473
  """
 
 
 
474
  self._stats["req_total"] += 1
475
 
476
  # 全局并发限制(如果已配置)
 
3
  支持:自动刷新 Session Token、外部触发指纹切换、死磕重试
4
  """
5
  import os
6
+ import sys
7
+ import subprocess
8
  # 修复 Windows 上 patchright 的 asyncio 兼容性问题
9
  os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0")
10
 
 
17
  from datetime import datetime
18
  from urllib.parse import urlparse, unquote
19
 
 
 
20
  from ..core.logger import debug_logger
21
 
22
 
23
+ # ==================== Docker 环境检测 ====================
24
+ def _is_running_in_docker() -> bool:
25
+ """检测是否在 Docker 容器中运行"""
26
+ # 方法1: 检查 /.dockerenv 文件
27
+ if os.path.exists('/.dockerenv'):
28
+ return True
29
+ # 方法2: 检查 cgroup
30
+ try:
31
+ with open('/proc/1/cgroup', 'r') as f:
32
+ content = f.read()
33
+ if 'docker' in content or 'kubepods' in content or 'containerd' in content:
34
+ return True
35
+ except:
36
+ pass
37
+ # 方法3: 检查环境变量
38
+ if os.environ.get('DOCKER_CONTAINER') or os.environ.get('KUBERNETES_SERVICE_HOST'):
39
+ return True
40
+ return False
41
+
42
+
43
+ IS_DOCKER = _is_running_in_docker()
44
+
45
+
46
+ # ==================== patchright 自动安装 ====================
47
+ def _run_pip_install(package: str, use_mirror: bool = False) -> bool:
48
+ """运行 pip install 命令"""
49
+ cmd = [sys.executable, '-m', 'pip', 'install', package]
50
+ if use_mirror:
51
+ cmd.extend(['-i', 'https://pypi.tuna.tsinghua.edu.cn/simple'])
52
+
53
+ try:
54
+ debug_logger.log_info(f"[BrowserCaptcha] 正在安装 {package}...")
55
+ print(f"[BrowserCaptcha] 正在安装 {package}...")
56
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
57
+ if result.returncode == 0:
58
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ {package} 安装成功")
59
+ print(f"[BrowserCaptcha] ✅ {package} 安装成功")
60
+ return True
61
+ else:
62
+ debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装失败: {result.stderr[:200]}")
63
+ return False
64
+ except Exception as e:
65
+ debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装异常: {e}")
66
+ return False
67
+
68
+
69
+ def _run_playwright_install(use_mirror: bool = False) -> bool:
70
+ """安装 patchright chromium 浏览器"""
71
+ cmd = [sys.executable, '-m', 'patchright', 'install', 'chromium']
72
+ env = os.environ.copy()
73
+
74
+ if use_mirror:
75
+ # 使用国内镜像
76
+ env['PLAYWRIGHT_DOWNLOAD_HOST'] = 'https://npmmirror.com/mirrors/playwright'
77
+
78
+ try:
79
+ debug_logger.log_info("[BrowserCaptcha] 正在安装 chromium 浏览器...")
80
+ print("[BrowserCaptcha] 正在安装 chromium 浏览器...")
81
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env)
82
+ if result.returncode == 0:
83
+ debug_logger.log_info("[BrowserCaptcha] ✅ chromium 浏览器安装成功")
84
+ print("[BrowserCaptcha] ✅ chromium 浏览器安装成功")
85
+ return True
86
+ else:
87
+ debug_logger.log_warning(f"[BrowserCaptcha] chromium 安装失败: {result.stderr[:200]}")
88
+ return False
89
+ except Exception as e:
90
+ debug_logger.log_warning(f"[BrowserCaptcha] chromium 安装异常: {e}")
91
+ return False
92
+
93
+
94
+ def _ensure_patchright_installed() -> bool:
95
+ """确保 patchright 已安装"""
96
+ try:
97
+ import patchright
98
+ debug_logger.log_info("[BrowserCaptcha] patchright 已安装")
99
+ return True
100
+ except ImportError:
101
+ pass
102
+
103
+ debug_logger.log_info("[BrowserCaptcha] patchright 未安装,开始自动安装...")
104
+ print("[BrowserCaptcha] patchright 未安装,开始自动安装...")
105
+
106
+ # 先尝试官方源
107
+ if _run_pip_install('patchright', use_mirror=False):
108
+ return True
109
+
110
+ # 官方源失败,尝试国内镜像
111
+ debug_logger.log_info("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
112
+ print("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
113
+ if _run_pip_install('patchright', use_mirror=True):
114
+ return True
115
+
116
+ debug_logger.log_error("[BrowserCaptcha] ❌ patchright 自动安装失败,请手动安装: pip install patchright")
117
+ print("[BrowserCaptcha] ❌ patchright 自动安装失败,请手动安装: pip install patchright")
118
+ return False
119
+
120
+
121
+ def _ensure_browser_installed() -> bool:
122
+ """确保 chromium 浏览器已安装"""
123
+ try:
124
+ from patchright.sync_api import sync_playwright
125
+ with sync_playwright() as p:
126
+ # 尝试获取浏览器路径,如果失败说明未安装
127
+ browser_path = p.chromium.executable_path
128
+ if browser_path and os.path.exists(browser_path):
129
+ debug_logger.log_info(f"[BrowserCaptcha] chromium 浏览器已安装: {browser_path}")
130
+ return True
131
+ except Exception as e:
132
+ debug_logger.log_info(f"[BrowserCaptcha] ��测浏览器时出错: {e}")
133
+
134
+ debug_logger.log_info("[BrowserCaptcha] chromium 浏览器未安装,开始自动安装...")
135
+ print("[BrowserCaptcha] chromium 浏览器未安装,开始自动安装...")
136
+
137
+ # 先尝试官方源
138
+ if _run_playwright_install(use_mirror=False):
139
+ return True
140
+
141
+ # 官方源失败,尝试国内镜像
142
+ debug_logger.log_info("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
143
+ print("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
144
+ if _run_playwright_install(use_mirror=True):
145
+ return True
146
+
147
+ debug_logger.log_error("[BrowserCaptcha] ❌ chromium 浏览器自动安装失败,请手动安装: python -m patchright install chromium")
148
+ print("[BrowserCaptcha] ❌ chromium 浏览器自动安装失败,请手动安装: python -m patchright install chromium")
149
+ return False
150
+
151
+
152
+ # 尝试导入 patchright
153
+ async_playwright = None
154
+ Route = None
155
+ BrowserContext = None
156
+ PATCHRIGHT_AVAILABLE = False
157
+
158
+ if IS_DOCKER:
159
+ debug_logger.log_warning("[BrowserCaptcha] 检测到 Docker 环境,有头浏览器打码不可用,请使用第三方打码服务")
160
+ print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,有头浏览器打码不可用")
161
+ print("[BrowserCaptcha] 请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver")
162
+ else:
163
+ if _ensure_patchright_installed():
164
+ try:
165
+ from patchright.async_api import async_playwright, Route, BrowserContext
166
+ PATCHRIGHT_AVAILABLE = True
167
+ # 检查并安装浏览器
168
+ _ensure_browser_installed()
169
+ except ImportError as e:
170
+ debug_logger.log_error(f"[BrowserCaptcha] patchright 导入失败: {e}")
171
+ print(f"[BrowserCaptcha] ❌ patchright 导入失败: {e}")
172
+
173
+
174
  # 配置
175
  LABS_URL = "https://labs.google/fx/tools/flow"
176
 
 
556
  await cls._instance._load_browser_count()
557
  return cls._instance
558
 
559
+ def _check_available(self):
560
+ """检查服务是否可用"""
561
+ if IS_DOCKER:
562
+ raise RuntimeError(
563
+ "有头浏览器打码在 Docker 环境中不可用。"
564
+ "请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver"
565
+ )
566
+ if not PATCHRIGHT_AVAILABLE or async_playwright is None:
567
+ raise RuntimeError(
568
+ "patchright 未安装或不可用。"
569
+ "请手动安装: pip install patchright && python -m patchright install chromium"
570
+ )
571
+
572
  async def _load_browser_count(self):
573
  """从数据库加载浏览器数量配置"""
574
  if self.db:
 
635
  Returns:
636
  (token, browser_id) 元组,调用方失败时用 browser_id 调用 report_error
637
  """
638
+ # 检查服务是否可用
639
+ self._check_available()
640
+
641
  self._stats["req_total"] += 1
642
 
643
  # 全局并发限制(如果已配置)
src/services/browser_captcha_personal.py CHANGED
@@ -6,13 +6,116 @@
6
  import asyncio
7
  import time
8
  import os
 
 
9
  from typing import Optional
10
 
11
- import nodriver as uc
12
-
13
  from ..core.logger import debug_logger
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  class ResidentTabInfo:
17
  """常驻标签页信息结构"""
18
  def __init__(self, tab, project_id: str):
@@ -61,9 +164,25 @@ class BrowserCaptchaService:
61
  if cls._instance is None:
62
  cls._instance = cls(db)
63
  return cls._instance
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  async def initialize(self):
66
  """初始化 nodriver 浏览器"""
 
 
 
67
  if self._initialized and self.browser:
68
  # 检查浏览器是否仍然存活
69
  try:
 
6
  import asyncio
7
  import time
8
  import os
9
+ import sys
10
+ import subprocess
11
  from typing import Optional
12
 
 
 
13
  from ..core.logger import debug_logger
14
 
15
 
16
+ # ==================== Docker 环境检测 ====================
17
+ def _is_running_in_docker() -> bool:
18
+ """检测是否在 Docker 容器中运行"""
19
+ # 方法1: 检查 /.dockerenv 文件
20
+ if os.path.exists('/.dockerenv'):
21
+ return True
22
+ # 方法2: 检查 cgroup
23
+ try:
24
+ with open('/proc/1/cgroup', 'r') as f:
25
+ content = f.read()
26
+ if 'docker' in content or 'kubepods' in content or 'containerd' in content:
27
+ return True
28
+ except:
29
+ pass
30
+ # 方法3: 检查环境变量
31
+ if os.environ.get('DOCKER_CONTAINER') or os.environ.get('KUBERNETES_SERVICE_HOST'):
32
+ return True
33
+ return False
34
+
35
+
36
+ IS_DOCKER = _is_running_in_docker()
37
+
38
+
39
+ # ==================== nodriver 自动安装 ====================
40
+ def _run_pip_install(package: str, use_mirror: bool = False) -> bool:
41
+ """运行 pip install 命令
42
+
43
+ Args:
44
+ package: 包名
45
+ use_mirror: 是否使用国内镜像
46
+
47
+ Returns:
48
+ 是否安装成功
49
+ """
50
+ cmd = [sys.executable, '-m', 'pip', 'install', package]
51
+ if use_mirror:
52
+ cmd.extend(['-i', 'https://pypi.tuna.tsinghua.edu.cn/simple'])
53
+
54
+ try:
55
+ debug_logger.log_info(f"[BrowserCaptcha] 正在安装 {package}...")
56
+ print(f"[BrowserCaptcha] 正在安装 {package}...")
57
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
58
+ if result.returncode == 0:
59
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ {package} 安装成功")
60
+ print(f"[BrowserCaptcha] ✅ {package} 安装成功")
61
+ return True
62
+ else:
63
+ debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装失败: {result.stderr[:200]}")
64
+ return False
65
+ except Exception as e:
66
+ debug_logger.log_warning(f"[BrowserCaptcha] {package} 安装异常: {e}")
67
+ return False
68
+
69
+
70
+ def _ensure_nodriver_installed() -> bool:
71
+ """确保 nodriver 已安装
72
+
73
+ Returns:
74
+ 是否安装成功/已安装
75
+ """
76
+ try:
77
+ import nodriver
78
+ debug_logger.log_info("[BrowserCaptcha] nodriver 已安装")
79
+ return True
80
+ except ImportError:
81
+ pass
82
+
83
+ debug_logger.log_info("[BrowserCaptcha] nodriver 未安装,开始自动安装...")
84
+ print("[BrowserCaptcha] nodriver 未安装,开始自动安装...")
85
+
86
+ # 先尝试官方源
87
+ if _run_pip_install('nodriver', use_mirror=False):
88
+ return True
89
+
90
+ # 官方源失败,尝试国内镜像
91
+ debug_logger.log_info("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
92
+ print("[BrowserCaptcha] 官方源安装失败,尝试国内镜像...")
93
+ if _run_pip_install('nodriver', use_mirror=True):
94
+ return True
95
+
96
+ debug_logger.log_error("[BrowserCaptcha] ❌ nodriver 自动安装失败,请手动安装: pip install nodriver")
97
+ print("[BrowserCaptcha] ❌ nodriver 自动安装失败,请手动安装: pip install nodriver")
98
+ return False
99
+
100
+
101
+ # 尝试导入 nodriver
102
+ uc = None
103
+ NODRIVER_AVAILABLE = False
104
+
105
+ if IS_DOCKER:
106
+ debug_logger.log_warning("[BrowserCaptcha] 检测到 Docker 环境,内置浏览器打码不可用,请使用第三方打码服务")
107
+ print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,内置浏览器打码不可用")
108
+ print("[BrowserCaptcha] 请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver")
109
+ else:
110
+ if _ensure_nodriver_installed():
111
+ try:
112
+ import nodriver as uc
113
+ NODRIVER_AVAILABLE = True
114
+ except ImportError as e:
115
+ debug_logger.log_error(f"[BrowserCaptcha] nodriver 导入失败: {e}")
116
+ print(f"[BrowserCaptcha] ❌ nodriver 导入失败: {e}")
117
+
118
+
119
  class ResidentTabInfo:
120
  """常驻标签页信息结构"""
121
  def __init__(self, tab, project_id: str):
 
164
  if cls._instance is None:
165
  cls._instance = cls(db)
166
  return cls._instance
167
+
168
+ def _check_available(self):
169
+ """检查服务是否可用"""
170
+ if IS_DOCKER:
171
+ raise RuntimeError(
172
+ "内置浏览器打码在 Docker 环境中不可用。"
173
+ "请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver"
174
+ )
175
+ if not NODRIVER_AVAILABLE or uc is None:
176
+ raise RuntimeError(
177
+ "nodriver 未安装或不可用。"
178
+ "请手动安装: pip install nodriver"
179
+ )
180
 
181
  async def initialize(self):
182
  """初始化 nodriver 浏览器"""
183
+ # 检查服务是否可用
184
+ self._check_available()
185
+
186
  if self._initialized and self.browser:
187
  # 检查浏览器是否仍然存活
188
  try:
src/services/flow_client.py CHANGED
@@ -1164,23 +1164,43 @@ class FlowClient:
1164
  """
1165
  captcha_method = config.captcha_method
1166
 
1167
- # 恒定浏览器打码
1168
  if captcha_method == "personal":
1169
  try:
1170
  from .browser_captcha_personal import BrowserCaptchaService
1171
  service = await BrowserCaptchaService.get_instance(self.db)
1172
  return await service.get_token(project_id, action), None
 
 
 
 
 
 
 
 
 
 
1173
  except Exception as e:
1174
- debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}")
1175
  return None, None
1176
- # 有头浏览器打码
1177
  elif captcha_method == "browser":
1178
  try:
1179
  from .browser_captcha import BrowserCaptchaService
1180
  service = await BrowserCaptchaService.get_instance(self.db)
1181
  return await service.get_token(project_id, action)
 
 
 
 
 
 
 
 
 
 
1182
  except Exception as e:
1183
- debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}")
1184
  return None, None
1185
  # API打码服务
1186
  elif captcha_method in ["yescaptcha", "capmonster", "ezcaptcha", "capsolver"]:
 
1164
  """
1165
  captcha_method = config.captcha_method
1166
 
1167
+ # 内置浏览器打码 (nodriver)
1168
  if captcha_method == "personal":
1169
  try:
1170
  from .browser_captcha_personal import BrowserCaptchaService
1171
  service = await BrowserCaptchaService.get_instance(self.db)
1172
  return await service.get_token(project_id, action), None
1173
+ except RuntimeError as e:
1174
+ # 捕获 Docker 环境或依赖缺失的明确错误
1175
+ error_msg = str(e)
1176
+ debug_logger.log_error(f"[reCAPTCHA Personal] {error_msg}")
1177
+ print(f"[reCAPTCHA] ❌ 内置浏览器打码失败: {error_msg}")
1178
+ return None, None
1179
+ except ImportError as e:
1180
+ debug_logger.log_error(f"[reCAPTCHA Personal] 导入失败: {str(e)}")
1181
+ print(f"[reCAPTCHA] ❌ nodriver 未安装,请运行: pip install nodriver")
1182
+ return None, None
1183
  except Exception as e:
1184
+ debug_logger.log_error(f"[reCAPTCHA Personal] 错误: {str(e)}")
1185
  return None, None
1186
+ # 有头浏览器打码 (patchright/playwright)
1187
  elif captcha_method == "browser":
1188
  try:
1189
  from .browser_captcha import BrowserCaptchaService
1190
  service = await BrowserCaptchaService.get_instance(self.db)
1191
  return await service.get_token(project_id, action)
1192
+ except RuntimeError as e:
1193
+ # 捕获 Docker 环境或依赖缺失的明确错误
1194
+ error_msg = str(e)
1195
+ debug_logger.log_error(f"[reCAPTCHA Browser] {error_msg}")
1196
+ print(f"[reCAPTCHA] ❌ 有头浏览器打码失败: {error_msg}")
1197
+ return None, None
1198
+ except ImportError as e:
1199
+ debug_logger.log_error(f"[reCAPTCHA Browser] 导入失败: {str(e)}")
1200
+ print(f"[reCAPTCHA] ❌ patchright 未安装,请运行: pip install patchright && python -m patchright install chromium")
1201
+ return None, None
1202
  except Exception as e:
1203
+ debug_logger.log_error(f"[reCAPTCHA Browser] 错误: {str(e)}")
1204
  return None, None
1205
  # API打码服务
1206
  elif captcha_method in ["yescaptcha", "capmonster", "ezcaptcha", "capsolver"]: