|
|
"""Generation handler for Flow2API""" |
|
|
import asyncio |
|
|
import base64 |
|
|
import json |
|
|
import time |
|
|
from typing import Optional, AsyncGenerator, List, Dict, Any |
|
|
from ..core.logger import debug_logger |
|
|
from ..core.config import config |
|
|
from ..core.models import Task, RequestLog |
|
|
from .file_cache import FileCache |
|
|
|
|
|
|
|
|
|
|
|
MODEL_CONFIG = { |
|
|
|
|
|
"gemini-2.5-flash-image-landscape": { |
|
|
"type": "image", |
|
|
"model_name": "GEM_PIX", |
|
|
"aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE" |
|
|
}, |
|
|
"gemini-2.5-flash-image-portrait": { |
|
|
"type": "image", |
|
|
"model_name": "GEM_PIX", |
|
|
"aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT" |
|
|
}, |
|
|
|
|
|
|
|
|
"gemini-3.0-pro-image-landscape": { |
|
|
"type": "image", |
|
|
"model_name": "GEM_PIX_2", |
|
|
"aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE" |
|
|
}, |
|
|
"gemini-3.0-pro-image-portrait": { |
|
|
"type": "image", |
|
|
"model_name": "GEM_PIX_2", |
|
|
"aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT" |
|
|
}, |
|
|
|
|
|
|
|
|
"imagen-4.0-generate-preview-landscape": { |
|
|
"type": "image", |
|
|
"model_name": "IMAGEN_3_5", |
|
|
"aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE" |
|
|
}, |
|
|
"imagen-4.0-generate-preview-portrait": { |
|
|
"type": "image", |
|
|
"model_name": "IMAGEN_3_5", |
|
|
"aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT" |
|
|
}, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
"veo_3_1_t2v_fast_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "t2v", |
|
|
"model_key": "veo_3_1_t2v_fast_portrait", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": False |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_3_1_t2v_fast_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "t2v", |
|
|
"model_key": "veo_3_1_t2v_fast", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": False |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_2_1_fast_d_15_t2v_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "t2v", |
|
|
"model_key": "veo_2_1_fast_d_15_t2v", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": False |
|
|
}, |
|
|
"veo_2_1_fast_d_15_t2v_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "t2v", |
|
|
"model_key": "veo_2_1_fast_d_15_t2v", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": False |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_2_0_t2v_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "t2v", |
|
|
"model_key": "veo_2_0_t2v", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": False |
|
|
}, |
|
|
"veo_2_0_t2v_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "t2v", |
|
|
"model_key": "veo_2_0_t2v", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": False |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_3_1_t2v_fast_portrait_ultra": { |
|
|
"type": "video", |
|
|
"video_type": "t2v", |
|
|
"model_key": "veo_3_1_t2v_fast_portrait_ultra", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": False |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_3_1_t2v_fast_portrait_ultra_relaxed": { |
|
|
"type": "video", |
|
|
"video_type": "t2v", |
|
|
"model_key": "veo_3_1_t2v_fast_portrait_ultra_relaxed", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": False |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_3_1_t2v_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "t2v", |
|
|
"model_key": "veo_3_1_t2v_portrait", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": False |
|
|
}, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
"veo_3_1_i2v_s_fast_fl_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_3_1_i2v_s_fast_fl", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
"veo_3_1_i2v_s_fast_fl_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_3_1_i2v_s_fast_fl", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_2_1_fast_d_15_i2v_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_2_1_fast_d_15_i2v", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
"veo_2_1_fast_d_15_i2v_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_2_1_fast_d_15_i2v", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_2_0_i2v_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_2_0_i2v", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
"veo_2_0_i2v_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_2_0_i2v", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_3_1_i2v_s_fast_ultra_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_3_1_i2v_s_fast_ultra", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
"veo_3_1_i2v_s_fast_ultra_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_3_1_i2v_s_fast_ultra", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_3_1_i2v_s_fast_ultra_relaxed_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_3_1_i2v_s_fast_ultra_relaxed", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
"veo_3_1_i2v_s_fast_ultra_relaxed_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_3_1_i2v_s_fast_ultra_relaxed", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_3_1_i2v_s_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_3_1_i2v_s", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
"veo_3_1_i2v_s_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "i2v", |
|
|
"model_key": "veo_3_1_i2v_s", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": True, |
|
|
"min_images": 1, |
|
|
"max_images": 2 |
|
|
}, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
"veo_3_0_r2v_fast_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "r2v", |
|
|
"model_key": "veo_3_0_r2v_fast", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": True, |
|
|
"min_images": 0, |
|
|
"max_images": None |
|
|
}, |
|
|
"veo_3_0_r2v_fast_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "r2v", |
|
|
"model_key": "veo_3_0_r2v_fast", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": True, |
|
|
"min_images": 0, |
|
|
"max_images": None |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_3_0_r2v_fast_ultra_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "r2v", |
|
|
"model_key": "veo_3_0_r2v_fast_ultra", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": True, |
|
|
"min_images": 0, |
|
|
"max_images": None |
|
|
}, |
|
|
"veo_3_0_r2v_fast_ultra_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "r2v", |
|
|
"model_key": "veo_3_0_r2v_fast_ultra", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": True, |
|
|
"min_images": 0, |
|
|
"max_images": None |
|
|
}, |
|
|
|
|
|
|
|
|
"veo_3_0_r2v_fast_ultra_relaxed_portrait": { |
|
|
"type": "video", |
|
|
"video_type": "r2v", |
|
|
"model_key": "veo_3_0_r2v_fast_ultra_relaxed", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", |
|
|
"supports_images": True, |
|
|
"min_images": 0, |
|
|
"max_images": None |
|
|
}, |
|
|
"veo_3_0_r2v_fast_ultra_relaxed_landscape": { |
|
|
"type": "video", |
|
|
"video_type": "r2v", |
|
|
"model_key": "veo_3_0_r2v_fast_ultra_relaxed", |
|
|
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", |
|
|
"supports_images": True, |
|
|
"min_images": 0, |
|
|
"max_images": None |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
class GenerationHandler: |
|
|
"""统一生成处理器""" |
|
|
|
|
|
def __init__(self, flow_client, token_manager, load_balancer, db, concurrency_manager, proxy_manager): |
|
|
self.flow_client = flow_client |
|
|
self.token_manager = token_manager |
|
|
self.load_balancer = load_balancer |
|
|
self.db = db |
|
|
self.concurrency_manager = concurrency_manager |
|
|
self.file_cache = FileCache( |
|
|
cache_dir="tmp", |
|
|
default_timeout=config.cache_timeout, |
|
|
proxy_manager=proxy_manager |
|
|
) |
|
|
|
|
|
async def check_token_availability(self, is_image: bool, is_video: bool) -> bool: |
|
|
"""检查Token可用性 |
|
|
|
|
|
Args: |
|
|
is_image: 是否检查图片生成Token |
|
|
is_video: 是否检查视频生成Token |
|
|
|
|
|
Returns: |
|
|
True表示有可用Token, False表示无可用Token |
|
|
""" |
|
|
token_obj = await self.load_balancer.select_token( |
|
|
for_image_generation=is_image, |
|
|
for_video_generation=is_video |
|
|
) |
|
|
return token_obj is not None |
|
|
|
|
|
async def handle_generation( |
|
|
self, |
|
|
model: str, |
|
|
prompt: str, |
|
|
images: Optional[List[bytes]] = None, |
|
|
stream: bool = False |
|
|
) -> AsyncGenerator: |
|
|
"""统一生成入口 |
|
|
|
|
|
Args: |
|
|
model: 模型名称 |
|
|
prompt: 提示词 |
|
|
images: 图片列表 (bytes格式) |
|
|
stream: 是否流式输出 |
|
|
""" |
|
|
start_time = time.time() |
|
|
token = None |
|
|
|
|
|
|
|
|
if model not in MODEL_CONFIG: |
|
|
error_msg = f"不支持的模型: {model}" |
|
|
debug_logger.log_error(error_msg) |
|
|
yield self._create_error_response(error_msg) |
|
|
return |
|
|
|
|
|
model_config = MODEL_CONFIG[model] |
|
|
generation_type = model_config["type"] |
|
|
debug_logger.log_info(f"[GENERATION] 开始生成 - 模型: {model}, 类型: {generation_type}, Prompt: {prompt[:50]}...") |
|
|
|
|
|
|
|
|
if not stream: |
|
|
is_image = (generation_type == "image") |
|
|
is_video = (generation_type == "video") |
|
|
available = await self.check_token_availability(is_image, is_video) |
|
|
|
|
|
if available: |
|
|
if is_image: |
|
|
message = "所有Token可用于图片生成。请启用流式模式使用生成功能。" |
|
|
else: |
|
|
message = "所有Token可用于视频生成。请启用流式模式使用生成功能。" |
|
|
else: |
|
|
if is_image: |
|
|
message = "没有可用的Token进行图片生成" |
|
|
else: |
|
|
message = "没有可用的Token进行视频生成" |
|
|
|
|
|
yield self._create_completion_response(message, is_availability_check=True) |
|
|
return |
|
|
|
|
|
|
|
|
if stream: |
|
|
yield self._create_stream_chunk( |
|
|
f"✨ {'视频' if generation_type == 'video' else '图片'}生成任务已启动\n", |
|
|
role="assistant" |
|
|
) |
|
|
|
|
|
|
|
|
debug_logger.log_info(f"[GENERATION] 正在选择可用Token...") |
|
|
|
|
|
if generation_type == "image": |
|
|
token = await self.load_balancer.select_token(for_image_generation=True, model=model) |
|
|
else: |
|
|
token = await self.load_balancer.select_token(for_video_generation=True, model=model) |
|
|
|
|
|
if not token: |
|
|
error_msg = self._get_no_token_error_message(generation_type) |
|
|
debug_logger.log_error(f"[GENERATION] {error_msg}") |
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"❌ {error_msg}\n") |
|
|
yield self._create_error_response(error_msg) |
|
|
return |
|
|
|
|
|
debug_logger.log_info(f"[GENERATION] 已选择Token: {token.id} ({token.email})") |
|
|
|
|
|
try: |
|
|
|
|
|
debug_logger.log_info(f"[GENERATION] 检查Token AT有效性...") |
|
|
if stream: |
|
|
yield self._create_stream_chunk("初始化生成环境...\n") |
|
|
|
|
|
if not await self.token_manager.is_at_valid(token.id): |
|
|
error_msg = "Token AT无效或刷新失败" |
|
|
debug_logger.log_error(f"[GENERATION] {error_msg}") |
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"❌ {error_msg}\n") |
|
|
yield self._create_error_response(error_msg) |
|
|
return |
|
|
|
|
|
|
|
|
token = await self.token_manager.get_token(token.id) |
|
|
|
|
|
|
|
|
debug_logger.log_info(f"[GENERATION] 检查/创建Project...") |
|
|
|
|
|
project_id = await self.token_manager.ensure_project_exists(token.id) |
|
|
debug_logger.log_info(f"[GENERATION] Project ID: {project_id}") |
|
|
|
|
|
|
|
|
if generation_type == "image": |
|
|
debug_logger.log_info(f"[GENERATION] 开始图片生成流程...") |
|
|
async for chunk in self._handle_image_generation( |
|
|
token, project_id, model_config, prompt, images, stream |
|
|
): |
|
|
yield chunk |
|
|
else: |
|
|
debug_logger.log_info(f"[GENERATION] 开始视频生成流程...") |
|
|
async for chunk in self._handle_video_generation( |
|
|
token, project_id, model_config, prompt, images, stream |
|
|
): |
|
|
yield chunk |
|
|
|
|
|
|
|
|
is_video = (generation_type == "video") |
|
|
await self.token_manager.record_usage(token.id, is_video=is_video) |
|
|
|
|
|
|
|
|
await self.token_manager.record_success(token.id) |
|
|
|
|
|
debug_logger.log_info(f"[GENERATION] ✅ 生成成功完成") |
|
|
|
|
|
|
|
|
duration = time.time() - start_time |
|
|
|
|
|
|
|
|
response_data = { |
|
|
"status": "success", |
|
|
"model": model, |
|
|
"prompt": prompt[:100] |
|
|
} |
|
|
|
|
|
|
|
|
if hasattr(self, '_last_generated_url') and self._last_generated_url: |
|
|
response_data["url"] = self._last_generated_url |
|
|
|
|
|
self._last_generated_url = None |
|
|
|
|
|
await self._log_request( |
|
|
token.id, |
|
|
f"generate_{generation_type}", |
|
|
{"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0}, |
|
|
response_data, |
|
|
200, |
|
|
duration |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
error_msg = f"生成失败: {str(e)}" |
|
|
debug_logger.log_error(f"[GENERATION] ❌ {error_msg}") |
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"❌ {error_msg}\n") |
|
|
if token: |
|
|
|
|
|
await self.token_manager.record_error(token.id) |
|
|
yield self._create_error_response(error_msg) |
|
|
|
|
|
|
|
|
duration = time.time() - start_time |
|
|
await self._log_request( |
|
|
token.id if token else None, |
|
|
f"generate_{generation_type if model_config else 'unknown'}", |
|
|
{"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0}, |
|
|
{"error": error_msg}, |
|
|
500, |
|
|
duration |
|
|
) |
|
|
|
|
|
def _get_no_token_error_message(self, generation_type: str) -> str: |
|
|
"""获取无可用Token时的详细错误信息""" |
|
|
if generation_type == "image": |
|
|
return "没有可用的Token进行图片生成。所有Token都处于禁用、冷却、锁定或已过期状态。" |
|
|
else: |
|
|
return "没有可用的Token进行视频生成。所有Token都处于禁用、冷却、配额耗尽或已过期状态。" |
|
|
|
|
|
async def _handle_image_generation( |
|
|
self, |
|
|
token, |
|
|
project_id: str, |
|
|
model_config: dict, |
|
|
prompt: str, |
|
|
images: Optional[List[bytes]], |
|
|
stream: bool |
|
|
) -> AsyncGenerator: |
|
|
"""处理图片生成 (同步返回)""" |
|
|
|
|
|
|
|
|
if self.concurrency_manager: |
|
|
if not await self.concurrency_manager.acquire_image(token.id): |
|
|
yield self._create_error_response("图片并发限制已达上限") |
|
|
return |
|
|
|
|
|
try: |
|
|
|
|
|
image_inputs = [] |
|
|
if images and len(images) > 0: |
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"上传 {len(images)} 张参考图片...\n") |
|
|
|
|
|
|
|
|
for idx, image_bytes in enumerate(images): |
|
|
media_id = await self.flow_client.upload_image( |
|
|
token.at, |
|
|
image_bytes, |
|
|
model_config["aspect_ratio"] |
|
|
) |
|
|
image_inputs.append({ |
|
|
"name": media_id, |
|
|
"imageInputType": "IMAGE_INPUT_TYPE_REFERENCE" |
|
|
}) |
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"已上传第 {idx + 1}/{len(images)} 张图片\n") |
|
|
|
|
|
|
|
|
if stream: |
|
|
yield self._create_stream_chunk("正在生成图片...\n") |
|
|
|
|
|
result = await self.flow_client.generate_image( |
|
|
at=token.at, |
|
|
project_id=project_id, |
|
|
prompt=prompt, |
|
|
model_name=model_config["model_name"], |
|
|
aspect_ratio=model_config["aspect_ratio"], |
|
|
image_inputs=image_inputs |
|
|
) |
|
|
|
|
|
|
|
|
media = result.get("media", []) |
|
|
if not media: |
|
|
yield self._create_error_response("生成结果为空") |
|
|
return |
|
|
|
|
|
image_url = media[0]["image"]["generatedImage"]["fifeUrl"] |
|
|
|
|
|
|
|
|
local_url = image_url |
|
|
if config.cache_enabled: |
|
|
try: |
|
|
if stream: |
|
|
yield self._create_stream_chunk("缓存图片中...\n") |
|
|
cached_filename = await self.file_cache.download_and_cache(image_url, "image") |
|
|
local_url = f"{self._get_base_url()}/tmp/{cached_filename}" |
|
|
if stream: |
|
|
yield self._create_stream_chunk("✅ 图片缓存成功,准备返回缓存地址...\n") |
|
|
except Exception as e: |
|
|
debug_logger.log_error(f"Failed to cache image: {str(e)}") |
|
|
|
|
|
local_url = image_url |
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"⚠️ 缓存失败: {str(e)}\n正在返回源链接...\n") |
|
|
else: |
|
|
if stream: |
|
|
yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n") |
|
|
|
|
|
|
|
|
|
|
|
self._last_generated_url = local_url |
|
|
|
|
|
if stream: |
|
|
yield self._create_stream_chunk( |
|
|
f"", |
|
|
finish_reason="stop" |
|
|
) |
|
|
else: |
|
|
yield self._create_completion_response( |
|
|
local_url, |
|
|
media_type="image" |
|
|
) |
|
|
|
|
|
finally: |
|
|
|
|
|
if self.concurrency_manager: |
|
|
await self.concurrency_manager.release_image(token.id) |
|
|
|
|
|
async def _handle_video_generation( |
|
|
self, |
|
|
token, |
|
|
project_id: str, |
|
|
model_config: dict, |
|
|
prompt: str, |
|
|
images: Optional[List[bytes]], |
|
|
stream: bool |
|
|
) -> AsyncGenerator: |
|
|
"""处理视频生成 (异步轮询)""" |
|
|
|
|
|
|
|
|
if self.concurrency_manager: |
|
|
if not await self.concurrency_manager.acquire_video(token.id): |
|
|
yield self._create_error_response("视频并发限制已达上限") |
|
|
return |
|
|
|
|
|
try: |
|
|
|
|
|
video_type = model_config.get("video_type") |
|
|
supports_images = model_config.get("supports_images", False) |
|
|
min_images = model_config.get("min_images", 0) |
|
|
max_images = model_config.get("max_images", 0) |
|
|
|
|
|
|
|
|
image_count = len(images) if images else 0 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if video_type == "t2v": |
|
|
if image_count > 0: |
|
|
if stream: |
|
|
yield self._create_stream_chunk("⚠️ 文生视频模型不支持上传图片,将忽略图片仅使用文本提示词生成\n") |
|
|
debug_logger.log_warning(f"[T2V] 模型 {model_config['model_key']} 不支持图片,已忽略 {image_count} 张图片") |
|
|
images = None |
|
|
image_count = 0 |
|
|
|
|
|
|
|
|
elif video_type == "i2v": |
|
|
if image_count < min_images or image_count > max_images: |
|
|
error_msg = f"❌ 首尾帧模型需要 {min_images}-{max_images} 张图片,当前提供了 {image_count} 张" |
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"{error_msg}\n") |
|
|
yield self._create_error_response(error_msg) |
|
|
return |
|
|
|
|
|
|
|
|
elif video_type == "r2v": |
|
|
|
|
|
pass |
|
|
|
|
|
|
|
|
start_media_id = None |
|
|
end_media_id = None |
|
|
reference_images = [] |
|
|
|
|
|
|
|
|
if video_type == "i2v" and images: |
|
|
if image_count == 1: |
|
|
|
|
|
if stream: |
|
|
yield self._create_stream_chunk("上传首帧图片...\n") |
|
|
start_media_id = await self.flow_client.upload_image( |
|
|
token.at, images[0], model_config["aspect_ratio"] |
|
|
) |
|
|
debug_logger.log_info(f"[I2V] 仅上传首帧: {start_media_id}") |
|
|
|
|
|
elif image_count == 2: |
|
|
|
|
|
if stream: |
|
|
yield self._create_stream_chunk("上传首帧和尾帧图片...\n") |
|
|
start_media_id = await self.flow_client.upload_image( |
|
|
token.at, images[0], model_config["aspect_ratio"] |
|
|
) |
|
|
end_media_id = await self.flow_client.upload_image( |
|
|
token.at, images[1], model_config["aspect_ratio"] |
|
|
) |
|
|
debug_logger.log_info(f"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}") |
|
|
|
|
|
|
|
|
elif video_type == "r2v" and images: |
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"上传 {image_count} 张参考图片...\n") |
|
|
|
|
|
for idx, img in enumerate(images): |
|
|
media_id = await self.flow_client.upload_image( |
|
|
token.at, img, model_config["aspect_ratio"] |
|
|
) |
|
|
reference_images.append({ |
|
|
"imageUsageType": "IMAGE_USAGE_TYPE_ASSET", |
|
|
"mediaId": media_id |
|
|
}) |
|
|
debug_logger.log_info(f"[R2V] 上传了 {len(reference_images)} 张参考图片") |
|
|
|
|
|
|
|
|
if stream: |
|
|
yield self._create_stream_chunk("提交视频生成任务...\n") |
|
|
|
|
|
|
|
|
if video_type == "i2v" and start_media_id: |
|
|
if end_media_id: |
|
|
|
|
|
result = await self.flow_client.generate_video_start_end( |
|
|
at=token.at, |
|
|
project_id=project_id, |
|
|
prompt=prompt, |
|
|
model_key=model_config["model_key"], |
|
|
aspect_ratio=model_config["aspect_ratio"], |
|
|
start_media_id=start_media_id, |
|
|
end_media_id=end_media_id, |
|
|
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" |
|
|
) |
|
|
else: |
|
|
|
|
|
result = await self.flow_client.generate_video_start_image( |
|
|
at=token.at, |
|
|
project_id=project_id, |
|
|
prompt=prompt, |
|
|
model_key=model_config["model_key"], |
|
|
aspect_ratio=model_config["aspect_ratio"], |
|
|
start_media_id=start_media_id, |
|
|
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" |
|
|
) |
|
|
|
|
|
|
|
|
elif video_type == "r2v" and reference_images: |
|
|
result = await self.flow_client.generate_video_reference_images( |
|
|
at=token.at, |
|
|
project_id=project_id, |
|
|
prompt=prompt, |
|
|
model_key=model_config["model_key"], |
|
|
aspect_ratio=model_config["aspect_ratio"], |
|
|
reference_images=reference_images, |
|
|
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" |
|
|
) |
|
|
|
|
|
|
|
|
else: |
|
|
result = await self.flow_client.generate_video_text( |
|
|
at=token.at, |
|
|
project_id=project_id, |
|
|
prompt=prompt, |
|
|
model_key=model_config["model_key"], |
|
|
aspect_ratio=model_config["aspect_ratio"], |
|
|
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" |
|
|
) |
|
|
|
|
|
|
|
|
operations = result.get("operations", []) |
|
|
if not operations: |
|
|
yield self._create_error_response("生成任务创建失败") |
|
|
return |
|
|
|
|
|
operation = operations[0] |
|
|
task_id = operation["operation"]["name"] |
|
|
scene_id = operation.get("sceneId") |
|
|
|
|
|
|
|
|
task = Task( |
|
|
task_id=task_id, |
|
|
token_id=token.id, |
|
|
model=model_config["model_key"], |
|
|
prompt=prompt, |
|
|
status="processing", |
|
|
scene_id=scene_id |
|
|
) |
|
|
await self.db.create_task(task) |
|
|
|
|
|
|
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"视频生成中...\n") |
|
|
|
|
|
async for chunk in self._poll_video_result(token, operations, stream): |
|
|
yield chunk |
|
|
|
|
|
finally: |
|
|
|
|
|
if self.concurrency_manager: |
|
|
await self.concurrency_manager.release_video(token.id) |
|
|
|
|
|
async def _poll_video_result( |
|
|
self, |
|
|
token, |
|
|
operations: List[Dict], |
|
|
stream: bool |
|
|
) -> AsyncGenerator: |
|
|
"""轮询视频生成结果""" |
|
|
|
|
|
max_attempts = config.max_poll_attempts |
|
|
poll_interval = config.poll_interval |
|
|
|
|
|
for attempt in range(max_attempts): |
|
|
await asyncio.sleep(poll_interval) |
|
|
|
|
|
try: |
|
|
result = await self.flow_client.check_video_status(token.at, operations) |
|
|
checked_operations = result.get("operations", []) |
|
|
|
|
|
if not checked_operations: |
|
|
continue |
|
|
|
|
|
operation = checked_operations[0] |
|
|
status = operation.get("status") |
|
|
|
|
|
|
|
|
progress_update_interval = 7 |
|
|
if stream and attempt % progress_update_interval == 0: |
|
|
progress = min(int((attempt / max_attempts) * 100), 95) |
|
|
yield self._create_stream_chunk(f"生成进度: {progress}%\n") |
|
|
|
|
|
|
|
|
if status == "MEDIA_GENERATION_STATUS_SUCCESSFUL": |
|
|
|
|
|
metadata = operation["operation"].get("metadata", {}) |
|
|
video_info = metadata.get("video", {}) |
|
|
video_url = video_info.get("fifeUrl") |
|
|
|
|
|
if not video_url: |
|
|
yield self._create_error_response("视频URL为空") |
|
|
return |
|
|
|
|
|
|
|
|
local_url = video_url |
|
|
if config.cache_enabled: |
|
|
try: |
|
|
if stream: |
|
|
yield self._create_stream_chunk("正在缓存视频文件...\n") |
|
|
cached_filename = await self.file_cache.download_and_cache(video_url, "video") |
|
|
local_url = f"{self._get_base_url()}/tmp/{cached_filename}" |
|
|
if stream: |
|
|
yield self._create_stream_chunk("✅ 视频缓存成功,准备返回缓存地址...\n") |
|
|
except Exception as e: |
|
|
debug_logger.log_error(f"Failed to cache video: {str(e)}") |
|
|
|
|
|
local_url = video_url |
|
|
if stream: |
|
|
yield self._create_stream_chunk(f"⚠️ 缓存失败: {str(e)}\n正在返回源链接...\n") |
|
|
else: |
|
|
if stream: |
|
|
yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n") |
|
|
|
|
|
|
|
|
task_id = operation["operation"]["name"] |
|
|
await self.db.update_task( |
|
|
task_id, |
|
|
status="completed", |
|
|
progress=100, |
|
|
result_urls=[local_url], |
|
|
completed_at=time.time() |
|
|
) |
|
|
|
|
|
|
|
|
self._last_generated_url = local_url |
|
|
|
|
|
|
|
|
if stream: |
|
|
yield self._create_stream_chunk( |
|
|
f"<video src='{local_url}' controls style='max-width:100%'></video>", |
|
|
finish_reason="stop" |
|
|
) |
|
|
else: |
|
|
yield self._create_completion_response( |
|
|
local_url, |
|
|
media_type="video" |
|
|
) |
|
|
return |
|
|
|
|
|
elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"): |
|
|
|
|
|
yield self._create_error_response(f"视频生成失败: {status}") |
|
|
return |
|
|
|
|
|
except Exception as e: |
|
|
debug_logger.log_error(f"Poll error: {str(e)}") |
|
|
continue |
|
|
|
|
|
|
|
|
yield self._create_error_response(f"视频生成超时 (已轮询{max_attempts}次)") |
|
|
|
|
|
|
|
|
|
|
|
def _create_stream_chunk(self, content: str, role: str = None, finish_reason: str = None) -> str: |
|
|
"""创建流式响应chunk""" |
|
|
import json |
|
|
import time |
|
|
|
|
|
chunk = { |
|
|
"id": f"chatcmpl-{int(time.time())}", |
|
|
"object": "chat.completion.chunk", |
|
|
"created": int(time.time()), |
|
|
"model": "flow2api", |
|
|
"choices": [{ |
|
|
"index": 0, |
|
|
"delta": {}, |
|
|
"finish_reason": finish_reason |
|
|
}] |
|
|
} |
|
|
|
|
|
if role: |
|
|
chunk["choices"][0]["delta"]["role"] = role |
|
|
|
|
|
if finish_reason: |
|
|
chunk["choices"][0]["delta"]["content"] = content |
|
|
else: |
|
|
chunk["choices"][0]["delta"]["reasoning_content"] = content |
|
|
|
|
|
return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" |
|
|
|
|
|
def _create_completion_response(self, content: str, media_type: str = "image", is_availability_check: bool = False) -> str: |
|
|
"""创建非流式响应 |
|
|
|
|
|
Args: |
|
|
content: 媒体URL或纯文本消息 |
|
|
media_type: 媒体类型 ("image" 或 "video") |
|
|
is_availability_check: 是否为可用性检查响应 (纯文本消息) |
|
|
|
|
|
Returns: |
|
|
JSON格式的响应 |
|
|
""" |
|
|
import json |
|
|
import time |
|
|
|
|
|
|
|
|
if is_availability_check: |
|
|
formatted_content = content |
|
|
else: |
|
|
|
|
|
if media_type == "video": |
|
|
formatted_content = f"```html\n<video src='{content}' controls></video>\n```" |
|
|
else: |
|
|
formatted_content = f"" |
|
|
|
|
|
response = { |
|
|
"id": f"chatcmpl-{int(time.time())}", |
|
|
"object": "chat.completion", |
|
|
"created": int(time.time()), |
|
|
"model": "flow2api", |
|
|
"choices": [{ |
|
|
"index": 0, |
|
|
"message": { |
|
|
"role": "assistant", |
|
|
"content": formatted_content |
|
|
}, |
|
|
"finish_reason": "stop" |
|
|
}] |
|
|
} |
|
|
|
|
|
return json.dumps(response, ensure_ascii=False) |
|
|
|
|
|
def _create_error_response(self, error_message: str) -> str: |
|
|
"""创建错误响应""" |
|
|
import json |
|
|
|
|
|
error = { |
|
|
"error": { |
|
|
"message": error_message, |
|
|
"type": "invalid_request_error", |
|
|
"code": "generation_failed" |
|
|
} |
|
|
} |
|
|
|
|
|
return json.dumps(error, ensure_ascii=False) |
|
|
|
|
|
def _get_base_url(self) -> str: |
|
|
"""获取基础URL用于缓存文件访问""" |
|
|
|
|
|
if config.cache_base_url: |
|
|
return config.cache_base_url |
|
|
|
|
|
return f"http://{config.server_host}:{config.server_port}" |
|
|
|
|
|
async def _log_request( |
|
|
self, |
|
|
token_id: Optional[int], |
|
|
operation: str, |
|
|
request_data: Dict[str, Any], |
|
|
response_data: Dict[str, Any], |
|
|
status_code: int, |
|
|
duration: float |
|
|
): |
|
|
"""记录请求到数据库""" |
|
|
try: |
|
|
log = RequestLog( |
|
|
token_id=token_id, |
|
|
operation=operation, |
|
|
request_body=json.dumps(request_data, ensure_ascii=False), |
|
|
response_body=json.dumps(response_data, ensure_ascii=False), |
|
|
status_code=status_code, |
|
|
duration=duration |
|
|
) |
|
|
await self.db.add_request_log(log) |
|
|
except Exception as e: |
|
|
|
|
|
debug_logger.log_error(f"Failed to log request: {e}") |
|
|
|
|
|
|