Add Grok and VEO video workflow support
This commit is contained in:
+3
-1
@@ -9,6 +9,8 @@ from .gemini_flash_client import GeminiFlashClient
|
||||
from .sora_client import SoraClient
|
||||
from .kling_client import KlingClient
|
||||
from .veo_client import VeoClient
|
||||
from .newapi_veo_client import NewAPIVeoClient
|
||||
from .grok_video_client import GrokVideoClient
|
||||
from .openai_client import OpenAIAPIClient
|
||||
|
||||
__all__ = ['BaseAPIClient', 'GeminiAPIClient', 'GeminiFlashClient', 'SoraClient', 'KlingClient', 'VeoClient', 'OpenAIAPIClient']
|
||||
__all__ = ['BaseAPIClient', 'GeminiAPIClient', 'GeminiFlashClient', 'SoraClient', 'KlingClient', 'VeoClient', 'NewAPIVeoClient', 'GrokVideoClient', 'OpenAIAPIClient']
|
||||
|
||||
+2
-22
@@ -40,7 +40,8 @@ class BaseAPIClient(ABC):
|
||||
Args:
|
||||
base_url: API 基础 URL
|
||||
api_key: API 密钥
|
||||
max_request_size: 最大请求体大小(字节),默认 100MB
|
||||
max_request_size: 兼容参数;基类不再用它限制 JSON 请求体,
|
||||
部分子类仍用它作为上传文件大小限制
|
||||
"""
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
@@ -116,24 +117,6 @@ class BaseAPIClient(ABC):
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def check_request_size(self, request_body: Dict[str, Any]) -> None:
|
||||
"""
|
||||
检查请求体大小是否超过限制
|
||||
|
||||
Args:
|
||||
request_body: 请求体字典
|
||||
|
||||
Raises:
|
||||
ValueError: 如果请求体超过限制
|
||||
"""
|
||||
request_json = json.dumps(request_body)
|
||||
request_size = len(request_json.encode('utf-8'))
|
||||
|
||||
if request_size > self.max_request_size:
|
||||
raise ValueError(
|
||||
"请求体积超过100MB限制,请调整分辨率或减少图片数量"
|
||||
)
|
||||
|
||||
def get_http_error_message(self, status_code: int, error_message: str) -> Optional[str]:
|
||||
"""
|
||||
子类可重写:为指定 HTTP 状态码返回自定义错误文案。
|
||||
@@ -185,9 +168,6 @@ class BaseAPIClient(ABC):
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = self.get_headers(use_bearer_token)
|
||||
|
||||
# 检查请求大小
|
||||
self.check_request_size(request_body)
|
||||
|
||||
close_session = False
|
||||
if session is None:
|
||||
session = self._make_session()
|
||||
|
||||
@@ -72,7 +72,9 @@ class GeminiAsyncImageProvider(BaseAsyncImageProvider):
|
||||
resolution=resolution,
|
||||
enable_grounding=kwargs.get("enable_grounding", False),
|
||||
enable_image_search=kwargs.get("enable_image_search", False),
|
||||
image_compression=getattr(self, 'image_compression', None),
|
||||
image_compression=getattr(self, "image_compression", None),
|
||||
thinking_level=kwargs.get("thinking_level"),
|
||||
request_log_enabled=False,
|
||||
)
|
||||
|
||||
def extract_task_id(self, response: dict) -> str:
|
||||
@@ -85,6 +87,23 @@ class GeminiAsyncImageProvider(BaseAsyncImageProvider):
|
||||
return response.get("status", "UNKNOWN")
|
||||
|
||||
async def parse_result(self, result_data: dict, session) -> List[Image.Image]:
|
||||
images = result_data.get("images") if isinstance(result_data, dict) else None
|
||||
if isinstance(images, list) and images:
|
||||
parsed = []
|
||||
for item in images:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
image_url = item.get("url") or item.get("image_url")
|
||||
if image_url:
|
||||
async with session.get(image_url) as img_resp:
|
||||
if img_resp.status == 200:
|
||||
img_bytes = await img_resp.read()
|
||||
parsed.append(Image.open(BytesIO(img_bytes)).convert("RGB"))
|
||||
else:
|
||||
raise RuntimeError(f"下载图片失败 ({img_resp.status}): {image_url}")
|
||||
if parsed:
|
||||
return parsed
|
||||
|
||||
# 异步接口可能直接返回 image_url
|
||||
image_url = result_data.get("image_url", "") if isinstance(result_data, dict) else ""
|
||||
if image_url:
|
||||
@@ -124,19 +143,44 @@ class GeminiAsyncImageProvider(BaseAsyncImageProvider):
|
||||
|
||||
def extract_progress(self, response: dict) -> Optional[float]:
|
||||
"""从轮询响应中提取进度(0.0-1.0)"""
|
||||
|
||||
def _coerce(val) -> Optional[float]:
|
||||
if val is None or isinstance(val, bool):
|
||||
return None
|
||||
if isinstance(val, (int, float)):
|
||||
progress = float(val)
|
||||
elif isinstance(val, str):
|
||||
text = val.strip()
|
||||
if not text:
|
||||
return None
|
||||
has_percent_suffix = text.endswith("%")
|
||||
if has_percent_suffix:
|
||||
text = text[:-1].strip()
|
||||
try:
|
||||
progress = float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if has_percent_suffix:
|
||||
progress /= 100.0
|
||||
else:
|
||||
return None
|
||||
if progress > 1.0:
|
||||
progress /= 100.0
|
||||
return max(0.0, min(progress, 1.0))
|
||||
|
||||
# 直接字段:progress / percentage
|
||||
for field in ("progress", "percentage"):
|
||||
val = response.get(field)
|
||||
if val is not None and isinstance(val, (int, float)):
|
||||
return val / 100.0 if val > 1 else float(val)
|
||||
for field in ("progress", "percentage", "percent"):
|
||||
progress = _coerce(response.get(field))
|
||||
if progress is not None:
|
||||
return progress
|
||||
|
||||
# 嵌套字段:progressInfo / progress_info
|
||||
progress_info = response.get("progressInfo") or response.get("progress_info")
|
||||
if isinstance(progress_info, dict):
|
||||
for field in ("progress", "percentage"):
|
||||
val = progress_info.get(field)
|
||||
if val is not None and isinstance(val, (int, float)):
|
||||
return val / 100.0 if val > 1 else float(val)
|
||||
for field in ("progress", "percentage", "percent"):
|
||||
progress = _coerce(progress_info.get(field))
|
||||
if progress is not None:
|
||||
return progress
|
||||
|
||||
return None
|
||||
|
||||
|
||||
+116
-93
@@ -34,8 +34,7 @@ class GeminiAPIClient(BaseAPIClient):
|
||||
|
||||
super().__init__(
|
||||
base_url=get_api_base_url(),
|
||||
api_key=api_key,
|
||||
max_request_size=100 * 1024 * 1024
|
||||
api_key=api_key
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -188,6 +187,7 @@ class GeminiAPIClient(BaseAPIClient):
|
||||
enable_image_search: bool = False,
|
||||
image_compression: str = None,
|
||||
thinking_level: str = None,
|
||||
request_log_enabled: bool = True,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -204,21 +204,90 @@ class GeminiAPIClient(BaseAPIClient):
|
||||
Returns:
|
||||
请求体字典
|
||||
"""
|
||||
import json
|
||||
|
||||
_MAX_BODY_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||
_BODY_TARGET_BYTES = int(_MAX_BODY_BYTES * 0.9)
|
||||
|
||||
parts = []
|
||||
|
||||
# 添加文本部分
|
||||
parts.append({"text": prompt})
|
||||
|
||||
image_config = {"imageSize": resolution}
|
||||
if aspect_ratio and aspect_ratio != "智能":
|
||||
image_config["aspectRatio"] = aspect_ratio
|
||||
|
||||
def _build_request_body(body_parts: List[dict]) -> Dict[str, Any]:
|
||||
body = {
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": body_parts
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["IMAGE"],
|
||||
"imageConfig": image_config
|
||||
}
|
||||
}
|
||||
|
||||
# 添加思考深度配置
|
||||
if thinking_level:
|
||||
body["generationConfig"]["thinkingConfig"] = {
|
||||
"thinkingLevel": thinking_level,
|
||||
"includeThoughts": True
|
||||
}
|
||||
|
||||
# 添加图片压缩参数
|
||||
if image_compression:
|
||||
body["image_compression"] = image_compression
|
||||
|
||||
# 添加 Google Search Grounding(如果启用)
|
||||
# 新异步接口要求直接放在请求体顶层:{"google_search": true}
|
||||
if enable_grounding or enable_image_search:
|
||||
body["google_search"] = True
|
||||
|
||||
return body
|
||||
|
||||
def _request_size(body: Dict[str, Any]) -> int:
|
||||
return len(json.dumps(body).encode("utf-8"))
|
||||
|
||||
def _format_size(size: int) -> str:
|
||||
if size < 1024 * 1024:
|
||||
return f"{size / 1024:.2f}KB"
|
||||
return f"{size / 1024 / 1024:.2f}MB"
|
||||
|
||||
def _shorten_base64_for_log(obj, max_len: int = 200):
|
||||
if isinstance(obj, dict):
|
||||
result = {}
|
||||
for key, value in obj.items():
|
||||
if key == "data" and isinstance(value, str) and len(value) > max_len:
|
||||
result[key] = f"<base64 data, {len(value)} chars>"
|
||||
else:
|
||||
result[key] = _shorten_base64_for_log(value, max_len)
|
||||
return result
|
||||
if isinstance(obj, list):
|
||||
return [_shorten_base64_for_log(item, max_len) for item in obj]
|
||||
return obj
|
||||
|
||||
def _log_original_request_body(body: Dict[str, Any]) -> None:
|
||||
body_size = _request_size(body)
|
||||
print(
|
||||
f"\n{'=' * 60}\n"
|
||||
f"[原始请求体日志] 请求体积: {_format_size(body_size)} "
|
||||
f"(inline_data.data 已折叠显示 base64 长度)\n"
|
||||
f"{json.dumps(_shorten_base64_for_log(body), ensure_ascii=False, indent=2)}\n"
|
||||
f"{'=' * 60}\n"
|
||||
)
|
||||
|
||||
original_request_logged = False
|
||||
|
||||
# 添加图像部分(如果有)
|
||||
if images:
|
||||
working_images = list(images)
|
||||
|
||||
# 编码一次,估算大小,超限则迭代缩放
|
||||
for _attempt in range(10):
|
||||
def _build_image_parts(src_images: List[Image.Image]) -> List[dict]:
|
||||
img_parts = []
|
||||
for img in working_images:
|
||||
for img in src_images:
|
||||
img_base64 = encode_image_to_base64(img)
|
||||
img_parts.append({
|
||||
"inline_data": {
|
||||
@@ -226,94 +295,49 @@ class GeminiAPIClient(BaseAPIClient):
|
||||
"data": img_base64
|
||||
}
|
||||
})
|
||||
return img_parts
|
||||
|
||||
# 估算完整 body 大小(不含工具字段,工具字段很小可忽略)
|
||||
est_image_config = {"imageSize": resolution}
|
||||
if aspect_ratio and aspect_ratio != "智能":
|
||||
est_image_config["aspectRatio"] = aspect_ratio
|
||||
estimated = self._estimate_body_size(
|
||||
parts + img_parts,
|
||||
{
|
||||
"generationConfig": {
|
||||
"responseModalities": ["IMAGE"],
|
||||
"imageConfig": est_image_config
|
||||
}
|
||||
}
|
||||
def _estimate_with_images(img_parts: List[dict]) -> int:
|
||||
return _request_size(_build_request_body(parts + img_parts))
|
||||
|
||||
working_images = list(images)
|
||||
img_parts = _build_image_parts(working_images)
|
||||
original_request_body = _build_request_body(parts + img_parts)
|
||||
if request_log_enabled:
|
||||
_log_original_request_body(original_request_body)
|
||||
original_request_logged = True
|
||||
estimated = _estimate_with_images(img_parts)
|
||||
|
||||
if estimated > _MAX_BODY_BYTES:
|
||||
ratio = _BODY_TARGET_BYTES / estimated
|
||||
scale = ratio ** 0.5 # 面积比 → 线性比
|
||||
working_images = self._scale_images_to_fit(working_images, scale)
|
||||
img_parts = _build_image_parts(working_images)
|
||||
estimated = _estimate_with_images(img_parts)
|
||||
|
||||
orig_sizes = ", ".join(f"{img.width}×{img.height}" for img in images)
|
||||
new_sizes = ", ".join(f"{img.width}×{img.height}" for img in working_images)
|
||||
size_mb = estimated / (1024 * 1024)
|
||||
target_mb = _BODY_TARGET_BYTES / (1024 * 1024)
|
||||
print(
|
||||
f"Nano Banana Pro: 输入图片已按请求体目标大小自动缩放\n"
|
||||
f" 原始尺寸: {orig_sizes}\n"
|
||||
f" 缩放后: {new_sizes}\n"
|
||||
f" 请求体积: {size_mb:.2f}MB(目标 {target_mb:.2f}MB,限制 20MB)"
|
||||
)
|
||||
|
||||
if estimated <= _MAX_BODY_BYTES:
|
||||
parts.extend(img_parts)
|
||||
if _attempt > 0:
|
||||
orig_sizes = ", ".join(
|
||||
f"{img.width}×{img.height}" for img in images
|
||||
)
|
||||
new_sizes = ", ".join(
|
||||
f"{img.width}×{img.height}" for img in working_images
|
||||
)
|
||||
size_mb = estimated / (1024 * 1024)
|
||||
print(
|
||||
f"Nano Banana Pro: 输入图片已自动缩放以控制请求体积\n"
|
||||
f" 原始尺寸: {orig_sizes}\n"
|
||||
f" 缩放后: {new_sizes}\n"
|
||||
f" 请求体积: {size_mb:.2f}MB(限制 20MB)"
|
||||
)
|
||||
break
|
||||
else:
|
||||
# 按像素面积比推算需要的线性缩放系数,留 5% 余量
|
||||
ratio = (_MAX_BODY_BYTES * 0.95) / estimated
|
||||
scale = ratio ** 0.5 # 面积比 → 线性比
|
||||
working_images = self._scale_images_to_fit(working_images, scale)
|
||||
else:
|
||||
# 10 轮后仍超限,使用最后一次结果(极端情况兜底)
|
||||
parts.extend(img_parts)
|
||||
parts.extend(img_parts)
|
||||
|
||||
# 构建请求体
|
||||
image_config = {"imageSize": resolution}
|
||||
if aspect_ratio and aspect_ratio != "智能":
|
||||
image_config["aspectRatio"] = aspect_ratio
|
||||
request_body = _build_request_body(parts)
|
||||
if request_log_enabled and not original_request_logged:
|
||||
_log_original_request_body(request_body)
|
||||
|
||||
request_body = {
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": parts
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["IMAGE"],
|
||||
"imageConfig": image_config
|
||||
}
|
||||
}
|
||||
|
||||
# 添加思考深度配置
|
||||
if thinking_level:
|
||||
request_body["generationConfig"]["thinkingConfig"] = {
|
||||
"thinkingLevel": thinking_level,
|
||||
"includeThoughts": True
|
||||
}
|
||||
|
||||
# 添加图片压缩参数
|
||||
if image_compression:
|
||||
request_body["image_compression"] = image_compression
|
||||
|
||||
# 添加 Google Search Grounding 工具(如果启用)
|
||||
# 注意:enable_image_search=True 时会自动隐含 enable_grounding
|
||||
if enable_grounding or enable_image_search:
|
||||
if enable_image_search:
|
||||
# 同时启用网页搜索和图片搜索(仅 nano-banana-2 / gemini-3.1-flash-image-preview 支持)
|
||||
request_body["tools"] = [
|
||||
{
|
||||
"google_search": {
|
||||
"searchTypes": {
|
||||
"webSearch": {},
|
||||
"imageSearch": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
else:
|
||||
# 仅启用网页搜索(通用)
|
||||
request_body["tools"] = [{"google_search": {}}]
|
||||
request_size = _request_size(request_body)
|
||||
if request_size > _MAX_BODY_BYTES:
|
||||
raise ValueError(
|
||||
f"请求体超过 20MB 限制(当前 {request_size / 1024 / 1024:.2f}MB),"
|
||||
"已停止提交;请减少参考图数量、降低图片复杂度或缩短提示词"
|
||||
)
|
||||
|
||||
return request_body
|
||||
|
||||
@@ -924,7 +948,7 @@ class GeminiAPIClient(BaseAPIClient):
|
||||
)
|
||||
|
||||
return self.run_async_in_thread(coro)
|
||||
|
||||
|
||||
async def generate_multi_prompts_async(
|
||||
self,
|
||||
prompts: List[str],
|
||||
@@ -1104,6 +1128,5 @@ class GeminiAPIClient(BaseAPIClient):
|
||||
enable_grounding=enable_grounding,
|
||||
enable_image_search=enable_image_search
|
||||
)
|
||||
|
||||
|
||||
return self.run_async_in_thread(coro)
|
||||
|
||||
+636
-10
@@ -1,13 +1,17 @@
|
||||
"""
|
||||
GPT Image API 客户端
|
||||
支持两个接口:
|
||||
- POST /v1/images/generations/ 文生图 / 图生图(gpt-image-1 / gpt-image-1.5)
|
||||
- POST /v1/images/edits/ 图像编辑(带蒙版 inpainting)
|
||||
新版节点请求走异步任务接口:
|
||||
- POST /async/v1/generateImage
|
||||
- GET /async/v1/tasks/{task_id}
|
||||
|
||||
旧同步接口保留兼容代码,但 GPT Image 节点不再使用:
|
||||
- POST /v1/images/generations/
|
||||
- POST /v1/images/edits
|
||||
|
||||
设计原则:
|
||||
- 与 doubao_image_client.py 保持相同的异步 + 同步双入口模式
|
||||
- generations / edits 接口均使用 multipart/form-data
|
||||
- 响应兼容 SSE 流式、JSON、url 和 b64_json
|
||||
- 对 ComfyUI 节点暴露同步入口,内部提交异步任务并轮询
|
||||
- 图片和蒙版以 data:image/png;base64,... 放入 JSON 请求体
|
||||
- 响应优先读取 data.images[].url
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -16,7 +20,7 @@ import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
from typing import List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
import numpy as np
|
||||
@@ -38,6 +42,8 @@ except ImportError:
|
||||
# ── 接口端点 ──────────────────────────────────────────────────────────────────
|
||||
_ENDPOINT_GENERATIONS = "/v1/images/generations/"
|
||||
_ENDPOINT_EDITS = "/v1/images/edits"
|
||||
_ENDPOINT_ASYNC_GENERATE = "/async/v1/generateImage"
|
||||
_ENDPOINT_ASYNC_TASK = "/async/v1/tasks/{task_id}"
|
||||
|
||||
# ── 模型名映射(UI 显示名 → API 实际参数名)─────────────────────────────────
|
||||
_MODEL_NAME_MAP = {
|
||||
@@ -47,6 +53,49 @@ _MODEL_NAME_MAP = {
|
||||
|
||||
# ── 超时 ──────────────────────────────────────────────────────────────────────
|
||||
_REQUEST_TIMEOUT = 900 # 秒
|
||||
_ASYNC_POLL_SCHEDULE = [5.0, 20.0]
|
||||
_ASYNC_POLL_INTERVAL = 3.0
|
||||
_ASYNC_MAX_WAIT = 600.0
|
||||
_ASYNC_RETRY_DELAYS = [2.0, 5.0, 10.0]
|
||||
_ASYNC_RETRYABLE_ERROR_CODES = {
|
||||
"image_rate_limited",
|
||||
"image_upstream_busy",
|
||||
"image_timeout",
|
||||
"image_storage_failed",
|
||||
"image_empty_result",
|
||||
"image_upstream_error",
|
||||
"image_internal_error",
|
||||
"image_unknown_error",
|
||||
}
|
||||
_ASYNC_RETRYABLE_ERROR_CATEGORIES = {
|
||||
"rate_limit",
|
||||
"upstream_busy",
|
||||
"timeout",
|
||||
"storage",
|
||||
"upstream_error",
|
||||
"internal_error",
|
||||
"unknown",
|
||||
}
|
||||
_ASYNC_NON_RETRYABLE_ERROR_CODES = {
|
||||
"image_invalid_size",
|
||||
"image_payload_too_large",
|
||||
"image_invalid_mask",
|
||||
"image_invalid_parameter",
|
||||
"image_safety_blocked",
|
||||
"image_provider_quota_exceeded",
|
||||
"image_provider_permission_required",
|
||||
"image_model_unavailable",
|
||||
"image_reference_download_failed",
|
||||
}
|
||||
|
||||
REQUEST_LOG_ENABLED = False
|
||||
POLL_LOG_ENABLED = False
|
||||
|
||||
|
||||
class _AsyncImageTaskFailure(RuntimeError):
|
||||
def __init__(self, message: str, error_detail: Optional[dict] = None):
|
||||
super().__init__(message)
|
||||
self.error_detail = error_detail or {}
|
||||
|
||||
|
||||
class GptImageClient:
|
||||
@@ -54,10 +103,10 @@ class GptImageClient:
|
||||
GPT Image API 客户端
|
||||
|
||||
接口说明:
|
||||
generations:multipart/form-data,支持 quality / size / n / model
|
||||
edits:multipart/form-data,图片和 mask 使用 PNG 文件上传
|
||||
async generateImage:JSON 提交,返回 task_id
|
||||
tasks/{task_id}:轮询任务状态,成功后读取 data.images[].url
|
||||
|
||||
响应支持 JSON 和 SSE 流式格式。
|
||||
旧 generations / edits 同步接口保留为兼容代码。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -132,6 +181,226 @@ class GptImageClient:
|
||||
)
|
||||
return png_bytes
|
||||
|
||||
@staticmethod
|
||||
def _png_bytes_to_data_url(png_bytes: bytes) -> str:
|
||||
b64 = base64.b64encode(png_bytes).decode("ascii")
|
||||
return f"data:image/png;base64,{b64}"
|
||||
|
||||
@staticmethod
|
||||
def _json_body_size(body: dict) -> int:
|
||||
return len(json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _format_body_size(size: int) -> str:
|
||||
if size < 1024 * 1024:
|
||||
return f"{size / 1024:.2f}KB"
|
||||
return f"{size / 1024 / 1024:.2f}MB"
|
||||
|
||||
@staticmethod
|
||||
def _shorten_data_urls_for_log(obj, max_len: int = 200):
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
key: GptImageClient._shorten_data_urls_for_log(value, max_len)
|
||||
for key, value in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [GptImageClient._shorten_data_urls_for_log(item, max_len) for item in obj]
|
||||
if isinstance(obj, str) and obj.startswith("data:image") and len(obj) > max_len:
|
||||
header, _, data = obj.partition(",")
|
||||
return f"{header},<base64 data, {len(data)} chars>"
|
||||
return obj
|
||||
|
||||
def _log_original_request_body(self, label: str, body: dict) -> None:
|
||||
if not REQUEST_LOG_ENABLED:
|
||||
return
|
||||
body_size = self._json_body_size(body)
|
||||
print(
|
||||
f"\n{'=' * 60}\n"
|
||||
f"[o1key GPT Image] 原始请求体日志 | {label} | "
|
||||
f"请求体积: {self._format_body_size(body_size)} "
|
||||
f"(data URL 已折叠显示 base64 长度)\n"
|
||||
f"{json.dumps(self._shorten_data_urls_for_log(body), ensure_ascii=False, indent=2)}\n"
|
||||
f"{'=' * 60}\n"
|
||||
)
|
||||
|
||||
def _log_original_response_body(self, label: str, text: str) -> None:
|
||||
if not REQUEST_LOG_ENABLED:
|
||||
return
|
||||
size = len(text.encode("utf-8"))
|
||||
print(
|
||||
f"\n{'=' * 60}\n"
|
||||
f"[o1key GPT Image] 原始返回响应体日志 | {label} | "
|
||||
f"响应体积: {self._format_body_size(size)}\n"
|
||||
f"{text}\n"
|
||||
f"{'=' * 60}\n"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_error_message(payload_or_text, status_code: int = 0) -> str:
|
||||
payload = payload_or_text
|
||||
if isinstance(payload_or_text, str):
|
||||
try:
|
||||
payload = json.loads(payload_or_text)
|
||||
except Exception:
|
||||
return get_friendly_message(status_code, payload_or_text)
|
||||
|
||||
if isinstance(payload, dict):
|
||||
error = payload.get("error")
|
||||
if isinstance(error, str) and error.strip():
|
||||
return error.strip()
|
||||
if isinstance(error, dict):
|
||||
msg = error.get("message") or error.get("msg") or error.get("error")
|
||||
if msg:
|
||||
return str(msg)
|
||||
return json.dumps(error, ensure_ascii=False)
|
||||
|
||||
msg = payload.get("message") or payload.get("msg")
|
||||
if msg:
|
||||
return str(msg)
|
||||
|
||||
return get_friendly_message(status_code, str(payload_or_text))
|
||||
|
||||
@staticmethod
|
||||
def _extract_error_detail(payload: dict) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
detail = payload.get("error_detail")
|
||||
return detail if isinstance(detail, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _should_retry_async_failure(error_detail: dict, retry_index: int) -> bool:
|
||||
if not isinstance(error_detail, dict) or not error_detail:
|
||||
return False
|
||||
|
||||
code = error_detail.get("code")
|
||||
category = error_detail.get("category")
|
||||
retryable = error_detail.get("retryable")
|
||||
|
||||
if code in _ASYNC_NON_RETRYABLE_ERROR_CODES:
|
||||
return False
|
||||
if code == "image_unknown_error":
|
||||
return retry_index == 0
|
||||
if retryable is True:
|
||||
return True
|
||||
if retryable is False:
|
||||
return False
|
||||
|
||||
return (
|
||||
code in _ASYNC_RETRYABLE_ERROR_CODES
|
||||
or category in _ASYNC_RETRYABLE_ERROR_CATEGORIES
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_progress_percent(value: Any) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value.strip().rstrip("%")
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
value = float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
elif isinstance(value, (int, float)):
|
||||
value = float(value)
|
||||
else:
|
||||
return None
|
||||
|
||||
if 0 <= value <= 1:
|
||||
value *= 100
|
||||
return max(0, min(100, int(round(value))))
|
||||
|
||||
@staticmethod
|
||||
def _emit_progress(progress_callback: Optional[Callable[[int], None]], pct: int) -> None:
|
||||
if progress_callback is None:
|
||||
return
|
||||
try:
|
||||
progress_callback(pct)
|
||||
except Exception as error:
|
||||
print(f"[o1key GPT Image] progress callback failed: {error}")
|
||||
|
||||
@staticmethod
|
||||
def _resize_png_bytes(source_image: Image.Image, scale: float) -> bytes:
|
||||
if scale < 0.999:
|
||||
width, height = source_image.size
|
||||
new_width = max(1, int(width * scale))
|
||||
new_height = max(1, int(height * scale))
|
||||
image = source_image.resize((new_width, new_height), Image.LANCZOS)
|
||||
else:
|
||||
image = source_image
|
||||
|
||||
buf = BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _pil_to_png_bytes(image: Image.Image) -> bytes:
|
||||
buf = BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
def _fit_png_assets_to_body_limit(self, assets: List[Dict[str, Any]], build_body) -> Dict[str, bytes]:
|
||||
"""
|
||||
根据完整 JSON 请求体大小压缩图片资产,保证最终 body 不超过 20MB。
|
||||
使用同一个缩放比例二分搜索,让压缩结果尽量贴近上限而不是过度压缩。
|
||||
"""
|
||||
asset_bytes = {asset["key"]: asset["bytes"] for asset in assets}
|
||||
initial_size = self._json_body_size(build_body(asset_bytes))
|
||||
if initial_size <= self._MAX_BODY_BYTES:
|
||||
return asset_bytes
|
||||
|
||||
if not assets:
|
||||
raise RuntimeError(
|
||||
f"请求体大小 {initial_size // 1024}KB 超过 20MB,且没有可压缩图片"
|
||||
)
|
||||
|
||||
originals = []
|
||||
for asset in assets:
|
||||
image = Image.open(BytesIO(asset["bytes"]))
|
||||
image.load()
|
||||
originals.append((asset, image.copy()))
|
||||
|
||||
low = 0.001
|
||||
high = 1.0
|
||||
best_bytes = None
|
||||
best_size = 0
|
||||
best_scale = 0.0
|
||||
|
||||
for _ in range(16):
|
||||
scale = (low + high) / 2
|
||||
candidate = {}
|
||||
for asset, image in originals:
|
||||
candidate[asset["key"]] = self._resize_png_bytes(image, scale)
|
||||
|
||||
body_size = self._json_body_size(build_body(candidate))
|
||||
if body_size <= self._MAX_BODY_BYTES:
|
||||
best_bytes = candidate
|
||||
best_size = body_size
|
||||
best_scale = scale
|
||||
low = scale
|
||||
else:
|
||||
high = scale
|
||||
|
||||
if best_bytes is None:
|
||||
candidate = {}
|
||||
for asset, image in originals:
|
||||
candidate[asset["key"]] = self._resize_png_bytes(image, low)
|
||||
body_size = self._json_body_size(build_body(candidate))
|
||||
if body_size > self._MAX_BODY_BYTES:
|
||||
raise RuntimeError(
|
||||
f"图片已压缩到最小比例,但请求体仍超过 20MB:{body_size // 1024}KB"
|
||||
)
|
||||
best_bytes = candidate
|
||||
best_size = body_size
|
||||
best_scale = low
|
||||
|
||||
print(
|
||||
f"[o1key GPT Image] 请求体超过 20MB,已等比压缩图片:"
|
||||
f"{initial_size // 1024}KB → {best_size // 1024}KB,scale={best_scale:.3f}"
|
||||
)
|
||||
return best_bytes
|
||||
|
||||
@staticmethod
|
||||
def _tensor_to_png_bytes(tensor: torch.Tensor) -> bytes:
|
||||
"""
|
||||
@@ -500,6 +769,316 @@ class GptImageClient:
|
||||
|
||||
return request_task.result()
|
||||
|
||||
# ── 新版异步 GPT Image 接口 ──────────────────────────────────────────────
|
||||
|
||||
def _build_async_generate_body(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
quality: str,
|
||||
size: str,
|
||||
n: int,
|
||||
image_list: Optional[List[torch.Tensor]] = None,
|
||||
mask_tensor: Optional[torch.Tensor] = None,
|
||||
output_format: str = "png",
|
||||
) -> dict:
|
||||
api_model = _MODEL_NAME_MAP.get(model, model)
|
||||
assets: List[Dict[str, Any]] = []
|
||||
image_keys: List[str] = []
|
||||
mask_key = None
|
||||
|
||||
if image_list:
|
||||
for idx_img, img_tensor in enumerate(image_list):
|
||||
pil_images = tensor_to_pil(img_tensor)
|
||||
if not pil_images:
|
||||
continue
|
||||
key = f"image_{idx_img}"
|
||||
image_keys.append(key)
|
||||
assets.append({
|
||||
"key": key,
|
||||
"label": f"参考图{idx_img + 1}",
|
||||
"bytes": self._pil_to_png_bytes(pil_images[0]),
|
||||
})
|
||||
|
||||
if mask_tensor is not None:
|
||||
if not image_list:
|
||||
raise ValueError("提供了蒙版但未提供图片,请同时提供图片和蒙版")
|
||||
|
||||
first_tensor = image_list[0]
|
||||
if first_tensor.dim() == 3:
|
||||
first_tensor = first_tensor.unsqueeze(0)
|
||||
image_size = (first_tensor.shape[1], first_tensor.shape[2])
|
||||
mask_key = "mask"
|
||||
assets.append({
|
||||
"key": mask_key,
|
||||
"label": "蒙版",
|
||||
"bytes": self._mask_tensor_to_rgba_png_bytes(mask_tensor, image_size),
|
||||
})
|
||||
|
||||
def _make_body(asset_bytes: Dict[str, bytes]) -> dict:
|
||||
body = {
|
||||
"model": api_model,
|
||||
"prompt": prompt,
|
||||
"images": [
|
||||
self._png_bytes_to_data_url(asset_bytes[key])
|
||||
for key in image_keys
|
||||
if key in asset_bytes
|
||||
],
|
||||
"size": size if size else "auto",
|
||||
"quality": quality,
|
||||
"n": int(n),
|
||||
"output_format": output_format or "png",
|
||||
}
|
||||
if mask_key and mask_key in asset_bytes:
|
||||
body["mask"] = {
|
||||
"image_url": self._png_bytes_to_data_url(asset_bytes[mask_key])
|
||||
}
|
||||
return body
|
||||
|
||||
original_asset_bytes = {asset["key"]: asset["bytes"] for asset in assets}
|
||||
original_body = _make_body(original_asset_bytes)
|
||||
self._log_original_request_body("async generateImage", original_body)
|
||||
|
||||
asset_bytes = self._fit_png_assets_to_body_limit(assets, _make_body)
|
||||
body = _make_body(asset_bytes)
|
||||
body_size = self._json_body_size(body)
|
||||
if body_size > self._MAX_BODY_BYTES:
|
||||
raise RuntimeError(f"请求体超过 20MB:{body_size // 1024}KB")
|
||||
|
||||
return body
|
||||
|
||||
async def _submit_generate_image_task(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
payload: dict,
|
||||
) -> str:
|
||||
url = f"{self.base_url}{_ENDPOINT_ASYNC_GENERATE}"
|
||||
last_status = None
|
||||
|
||||
for attempt in range(DEFAULT_MAX_RETRIES + 1):
|
||||
t0 = time.time()
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=self._json_headers(),
|
||||
) as resp:
|
||||
elapsed = time.time() - t0
|
||||
text = await resp.text()
|
||||
self._log_original_response_body(
|
||||
f"submit generateImage status={resp.status}",
|
||||
text,
|
||||
)
|
||||
|
||||
if resp.status not in (200, 201, 202):
|
||||
last_status = resp.status
|
||||
if resp.status in RETRYABLE_STATUS_CODES and attempt < DEFAULT_MAX_RETRIES:
|
||||
friendly = get_friendly_message(resp.status)
|
||||
delay = _compute_delay(
|
||||
attempt,
|
||||
DEFAULT_BASE_DELAY,
|
||||
DEFAULT_MAX_DELAY,
|
||||
DEFAULT_BACKOFF_FACTOR,
|
||||
)
|
||||
print(f"[o1key GPT Image] {friendly} {delay:.1f}s 后重试提交 ({attempt+1}/{DEFAULT_MAX_RETRIES})...")
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise RuntimeError(self._extract_error_message(text, resp.status))
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
raise RuntimeError(f"提交响应 JSON 解析失败,原始内容:{text[:500]}") from None
|
||||
|
||||
task_id = data.get("task_id")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"提交响应中未找到 task_id: {data}")
|
||||
|
||||
status = data.get("status", "")
|
||||
print(f"[o1key GPT Image] 异步任务已提交 | task_id={task_id} | status={status} | 耗时 {elapsed:.1f}s")
|
||||
return task_id
|
||||
|
||||
if last_status and last_status in HTTP_ERROR_MESSAGES:
|
||||
raise RuntimeError(HTTP_ERROR_MESSAGES[last_status])
|
||||
raise RuntimeError(f"异步任务提交失败: 重试 {DEFAULT_MAX_RETRIES} 次后仍然失败")
|
||||
|
||||
async def _poll_generate_image_task(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
task_id: str,
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
) -> dict:
|
||||
url = f"{self.base_url}{_ENDPOINT_ASYNC_TASK.format(task_id=task_id)}"
|
||||
start_time = time.time()
|
||||
|
||||
poll_count = 0
|
||||
last_poll_at = start_time
|
||||
|
||||
while True:
|
||||
if poll_count < len(_ASYNC_POLL_SCHEDULE):
|
||||
next_poll_at = start_time + _ASYNC_POLL_SCHEDULE[poll_count]
|
||||
else:
|
||||
next_poll_at = last_poll_at + _ASYNC_POLL_INTERVAL
|
||||
|
||||
sleep_time = next_poll_at - time.time()
|
||||
if sleep_time > 0:
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
last_poll_at = time.time()
|
||||
elapsed = last_poll_at - start_time
|
||||
if elapsed > _ASYNC_MAX_WAIT:
|
||||
raise RuntimeError(f"任务 {task_id} 超时(>{int(_ASYNC_MAX_WAIT)}秒),请稍后用 task_id 查询结果")
|
||||
|
||||
poll_count += 1
|
||||
async with session.get(url, headers=self._auth_headers()) as resp:
|
||||
text = await resp.text()
|
||||
self._log_original_response_body(
|
||||
f"poll task status={resp.status}",
|
||||
text,
|
||||
)
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(self._extract_error_message(text, resp.status))
|
||||
|
||||
try:
|
||||
task = json.loads(text)
|
||||
except Exception:
|
||||
raise RuntimeError(f"任务查询响应 JSON 解析失败,原始内容:{text[:500]}") from None
|
||||
|
||||
status = task.get("status", "UNKNOWN")
|
||||
progress = task.get("progress")
|
||||
progress_pct = self._coerce_progress_percent(progress)
|
||||
if POLL_LOG_ENABLED:
|
||||
progress_text = f" | progress={progress}" if progress is not None else ""
|
||||
print(f"[o1key GPT Image] 查询任务 #{poll_count} | task_id={task_id} | status={status}{progress_text}")
|
||||
|
||||
if status == "SUCCESS":
|
||||
self._emit_progress(progress_callback, 100)
|
||||
return task
|
||||
if progress_pct is not None and progress_pct < 100:
|
||||
self._emit_progress(progress_callback, progress_pct)
|
||||
if status == "FAILURE":
|
||||
error_message = self._extract_error_message(task, 500) or "生成失败"
|
||||
raise _AsyncImageTaskFailure(
|
||||
error_message,
|
||||
self._extract_error_detail(task),
|
||||
)
|
||||
if status not in ("SUBMITTED", "IN_PROGRESS"):
|
||||
raise RuntimeError(f"未知任务状态 {status}: {task}")
|
||||
|
||||
async def _parse_async_task_images(
|
||||
self,
|
||||
task: dict,
|
||||
session: aiohttp.ClientSession,
|
||||
) -> List[Image.Image]:
|
||||
data = task.get("data", {})
|
||||
image_items = data.get("images") if isinstance(data, dict) else None
|
||||
|
||||
if not isinstance(image_items, list) or not image_items:
|
||||
raise RuntimeError(f"任务结果中未找到 data.images: {task}")
|
||||
|
||||
images: List[Image.Image] = []
|
||||
for idx, item in enumerate(image_items, 1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
url = item.get("url") or item.get("image_url")
|
||||
b64 = item.get("b64_json", "")
|
||||
|
||||
if url and isinstance(url, str) and url.startswith("data:image"):
|
||||
try:
|
||||
_, b64_data = url.split(",", 1)
|
||||
img = Image.open(BytesIO(base64.b64decode(b64_data)))
|
||||
images.append(img)
|
||||
print(f"[o1key GPT Image] 第 {idx} 张 data URL 解码完成 ({img.size[0]}×{img.size[1]})")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"第 {idx} 张 data URL 解码失败: {e}") from None
|
||||
elif url and isinstance(url, str) and url.startswith("http"):
|
||||
async with session.get(url, allow_redirects=True) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"图像下载失败 HTTP {resp.status},URL: {url}")
|
||||
img_bytes = await resp.read()
|
||||
img = Image.open(BytesIO(img_bytes))
|
||||
images.append(img)
|
||||
print(f"[o1key GPT Image] 第 {idx} 张 URL 下载完成 ({img.size[0]}×{img.size[1]}) | {url}")
|
||||
elif b64:
|
||||
img = self._decode_b64_image(b64, f"第 {idx} 张")
|
||||
images.append(img)
|
||||
else:
|
||||
print(f"[o1key GPT Image] 警告:第 {idx} 条结果既无 url 也无 b64_json,已跳过")
|
||||
|
||||
if not images:
|
||||
raise RuntimeError("任务成功但没有可用图片结果")
|
||||
|
||||
return images
|
||||
|
||||
async def _generate_image_task_async(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
quality: str,
|
||||
size: str,
|
||||
n: int,
|
||||
seed: int,
|
||||
image_tensor: Optional[List[torch.Tensor]] = None,
|
||||
mask_tensor: Optional[torch.Tensor] = None,
|
||||
output_format: str = "png",
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
) -> List[Image.Image]:
|
||||
body = self._build_async_generate_body(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
quality=quality,
|
||||
size=size,
|
||||
n=n,
|
||||
image_list=image_tensor,
|
||||
mask_tensor=mask_tensor,
|
||||
output_format=output_format,
|
||||
)
|
||||
|
||||
mode = "图像编辑" if mask_tensor is not None else ("图生图" if image_tensor else "文生图")
|
||||
body_size = self._json_body_size(body)
|
||||
print(
|
||||
f"[o1key GPT Image] {mode} | 新异步接口 | 模型={model} | "
|
||||
f"quality={quality} | size={size} | n={n} | body={body_size // 1024}KB"
|
||||
)
|
||||
|
||||
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
|
||||
timeout = aiohttp.ClientTimeout(total=_ASYNC_MAX_WAIT + 120)
|
||||
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||||
last_error = None
|
||||
for retry_index in range(len(_ASYNC_RETRY_DELAYS) + 1):
|
||||
try:
|
||||
task_id = await self._submit_generate_image_task(session, body)
|
||||
task = await self._poll_generate_image_task(
|
||||
session,
|
||||
task_id,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
return await self._parse_async_task_images(task, session)
|
||||
except _AsyncImageTaskFailure as error:
|
||||
last_error = error
|
||||
detail = error.error_detail
|
||||
if (
|
||||
retry_index < len(_ASYNC_RETRY_DELAYS)
|
||||
and self._should_retry_async_failure(detail, retry_index)
|
||||
):
|
||||
delay = _ASYNC_RETRY_DELAYS[retry_index]
|
||||
code = detail.get("code", "unknown")
|
||||
category = detail.get("category", "unknown")
|
||||
failed_task_id = detail.get("task_id", "")
|
||||
task_text = f" | failed_task_id={failed_task_id}" if failed_task_id else ""
|
||||
print(
|
||||
f"[o1key GPT Image] 任务失败但可重试 | code={code} | "
|
||||
f"category={category}{task_text} | {delay:.0f}s 后重试 "
|
||||
f"({retry_index + 1}/{len(_ASYNC_RETRY_DELAYS)})"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise RuntimeError(str(error)) from None
|
||||
|
||||
if last_error is not None:
|
||||
raise RuntimeError(str(last_error)) from None
|
||||
raise RuntimeError("生成失败")
|
||||
|
||||
# ── 文生图 / 图生图(generations 接口)───────────────────────────────────
|
||||
|
||||
async def _generate_async(
|
||||
@@ -806,6 +1385,53 @@ class GptImageClient:
|
||||
f"o1key GPT Image 请求超时(>{_REQUEST_TIMEOUT}s),请检查网络或稍后重试"
|
||||
)
|
||||
|
||||
def generate_image_async_sync(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
quality: str,
|
||||
size: str,
|
||||
n: int,
|
||||
seed: int,
|
||||
image_tensor: Optional[List[torch.Tensor]] = None,
|
||||
mask_tensor: Optional[torch.Tensor] = None,
|
||||
output_format: str = "png",
|
||||
progress_callback: Optional[Callable[[int], None]] = None,
|
||||
) -> List[Image.Image]:
|
||||
"""
|
||||
新版异步任务入口,供节点调用。
|
||||
旧 run_sync 保留兼容,但 GPT Image 节点不再使用旧同步接口。
|
||||
"""
|
||||
coro = self._generate_image_task_async(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
quality=quality,
|
||||
size=size,
|
||||
n=n,
|
||||
seed=seed,
|
||||
image_tensor=image_tensor,
|
||||
mask_tensor=mask_tensor,
|
||||
output_format=output_format,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
def _run():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(self._run_with_interrupt(coro))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(_run)
|
||||
try:
|
||||
return future.result(timeout=_ASYNC_MAX_WAIT + 150)
|
||||
except TimeoutError:
|
||||
raise RuntimeError(
|
||||
f"o1key GPT Image 异步任务超时(>{int(_ASYNC_MAX_WAIT)}秒),请稍后用 task_id 查询结果"
|
||||
)
|
||||
|
||||
# ── 余额查询 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _query_balance_async(self) -> dict:
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Grok Video API client.
|
||||
|
||||
Flow:
|
||||
1. POST /v1/videos
|
||||
2. GET /v1/videos/{task_id}
|
||||
3. GET /v1/videos/{task_id}/content, or download a URL from the status body
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .base_client import BaseAPIClient
|
||||
from ..utils.config import get_api_base_url, get_api_key_or_raise
|
||||
from ..utils.http_error import RETRYABLE_STATUS_CODES, get_friendly_message
|
||||
from ..utils.video_task import (
|
||||
check_interrupt,
|
||||
extract_error_message,
|
||||
extract_progress,
|
||||
extract_status,
|
||||
extract_video_url,
|
||||
interruptible_sleep,
|
||||
is_failure_status,
|
||||
is_success_status,
|
||||
run_with_interrupt,
|
||||
)
|
||||
|
||||
|
||||
class GrokVideoClient(BaseAPIClient):
|
||||
CREATE_ENDPOINT = "/v1/videos"
|
||||
STATUS_ENDPOINT = "/v1/videos/{task_id}"
|
||||
CONTENT_ENDPOINT = "/v1/videos/{task_id}/content"
|
||||
|
||||
MODEL_OPTIONS = ["grok-imagine-video-1.5-preview", "grok-imagine-1.0-video"]
|
||||
ASPECT_RATIO_OPTIONS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]
|
||||
QUALITY_OPTIONS = ["720p"]
|
||||
MODEL_SECONDS_OPTIONS = {
|
||||
"grok-imagine-1.0-video": [6, 10, 12, 16, 20],
|
||||
}
|
||||
QUALITY_API_MAP = {
|
||||
"720p": "high",
|
||||
"high": "high",
|
||||
}
|
||||
|
||||
SUCCESS_STATUSES = {"complete", "completed", "succeed", "succeeded", "success", "done", "finished"}
|
||||
FAILURE_STATUSES = {"fail", "failed", "failure", "error", "expired", "timeout", "cancelled", "canceled"}
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None):
|
||||
api_key = get_api_key_or_raise("O1KEY_API_KEY")
|
||||
resolved_base_url = (base_url or "").strip() or get_api_base_url()
|
||||
super().__init__(base_url=resolved_base_url.rstrip("/"), api_key=api_key)
|
||||
|
||||
def get_endpoint(self, **kwargs) -> str:
|
||||
return self.CREATE_ENDPOINT
|
||||
|
||||
def build_request_body(self, **kwargs) -> Dict[str, Any]:
|
||||
return self.build_video_body(**kwargs)
|
||||
|
||||
def parse_response(self, response: Dict[str, Any]) -> Any:
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def build_video_body(
|
||||
cls,
|
||||
prompt: str,
|
||||
model: str,
|
||||
aspect_ratio: str,
|
||||
seconds: int,
|
||||
quality: str = "720p",
|
||||
images: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
prompt = (prompt or "").strip()
|
||||
if not prompt:
|
||||
raise ValueError("提示词不能为空。")
|
||||
|
||||
if model not in cls.MODEL_OPTIONS:
|
||||
raise ValueError(f"模型仅支持: {', '.join(cls.MODEL_OPTIONS)}。")
|
||||
|
||||
if aspect_ratio not in cls.ASPECT_RATIO_OPTIONS:
|
||||
raise ValueError(f"宽高比仅支持: {', '.join(cls.ASPECT_RATIO_OPTIONS)}。")
|
||||
|
||||
try:
|
||||
seconds_value = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("秒数必须是整数。") from None
|
||||
|
||||
allowed_seconds = cls.MODEL_SECONDS_OPTIONS.get(model)
|
||||
if allowed_seconds is not None:
|
||||
if seconds_value not in allowed_seconds:
|
||||
raise ValueError(
|
||||
f"模型 {model} 仅支持秒数: "
|
||||
f"{', '.join(str(s) for s in allowed_seconds)}。"
|
||||
"请修改为正确的秒数后再发起请求。"
|
||||
)
|
||||
elif seconds_value < 5 or seconds_value > 15:
|
||||
raise ValueError("秒数仅支持 5 到 15。")
|
||||
|
||||
api_quality = cls.QUALITY_API_MAP.get(str(quality), str(quality))
|
||||
if api_quality != "high":
|
||||
raise ValueError("画质仅支持 720p。")
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"seconds": str(seconds_value),
|
||||
"quality": api_quality,
|
||||
}
|
||||
|
||||
image_list = [img for img in (images or []) if img]
|
||||
if image_list:
|
||||
body["images"] = image_list[:3]
|
||||
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def _safe_task_filename(task_id: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", task_id).strip("._")
|
||||
return safe or "grok_video"
|
||||
|
||||
@staticmethod
|
||||
def _mask_body_for_log(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
log_body = dict(body)
|
||||
images = log_body.get("images")
|
||||
if isinstance(images, list):
|
||||
log_body["images"] = [f"<data-url chars={len(item)}>" for item in images]
|
||||
return log_body
|
||||
|
||||
@staticmethod
|
||||
def _extract_task_id(payload: Dict[str, Any]) -> Optional[str]:
|
||||
sources = [payload]
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
sources.append(data)
|
||||
|
||||
for source in sources:
|
||||
for key in ("id", "task_id", "video_id"):
|
||||
value = source.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_http_error(endpoint: str, status: int, error_text: str, task_id: Optional[str] = None) -> str:
|
||||
message = get_friendly_message(status, error_text)
|
||||
parts = [
|
||||
"Grok Video 请求失败。",
|
||||
f"endpoint: {endpoint}",
|
||||
f"http_status: {status}",
|
||||
]
|
||||
if task_id:
|
||||
parts.append(f"task_id: {task_id}")
|
||||
if message:
|
||||
parts.append(f"message: {message}")
|
||||
return "\n".join(parts)
|
||||
|
||||
@classmethod
|
||||
def _format_task_failure(cls, task_id: str, payload: Dict[str, Any]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"Grok Video 任务失败。",
|
||||
f"endpoint: {cls.STATUS_ENDPOINT.format(task_id=task_id)}",
|
||||
f"task_id: {task_id}",
|
||||
f"message: {extract_error_message(payload)}",
|
||||
]
|
||||
)
|
||||
|
||||
async def _request_json_with_retry(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
session: aiohttp.ClientSession,
|
||||
task_id: Optional[str] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
max_retries: int = 3,
|
||||
timeout_seconds: int = 120,
|
||||
) -> Dict[str, Any]:
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = self.get_headers(use_bearer_token=True)
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_seconds, connect=30, sock_read=timeout_seconds)
|
||||
|
||||
last_status = 0
|
||||
last_text = ""
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
check_interrupt()
|
||||
response = None
|
||||
try:
|
||||
response = await run_with_interrupt(
|
||||
session.request(method, url, json=json_body, headers=headers, timeout=timeout)
|
||||
)
|
||||
text = await run_with_interrupt(response.text())
|
||||
last_status = response.status
|
||||
last_text = text
|
||||
|
||||
if 200 <= response.status < 300:
|
||||
if not text.strip():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
raise RuntimeError(f"Grok Video 响应 JSON 解析失败,原始内容:{text[:500]}") from None
|
||||
|
||||
if response.status in RETRYABLE_STATUS_CODES and attempt < max_retries:
|
||||
delay = min(2 ** attempt, 8)
|
||||
print(
|
||||
f"Grok Video:{get_friendly_message(response.status)} "
|
||||
f"{delay}s 后重试 ({attempt + 1}/{max_retries})..."
|
||||
)
|
||||
await interruptible_sleep(delay)
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
||||
if attempt < max_retries:
|
||||
delay = min(2 ** attempt, 8)
|
||||
print(f"Grok Video:网络错误,{delay}s 后重试 ({attempt + 1}/{max_retries})...")
|
||||
await interruptible_sleep(delay)
|
||||
continue
|
||||
raise RuntimeError(f"Grok Video 网络错误: {e}") from None
|
||||
|
||||
finally:
|
||||
if response is not None:
|
||||
response.release()
|
||||
|
||||
raise RuntimeError(self._format_http_error(endpoint, last_status, last_text, task_id=task_id))
|
||||
|
||||
async def create_video_async(
|
||||
self,
|
||||
body: Dict[str, Any],
|
||||
session: aiohttp.ClientSession,
|
||||
) -> Dict[str, Any]:
|
||||
print("Grok Video:正在提交任务...")
|
||||
return await self._request_json_with_retry(
|
||||
"POST",
|
||||
self.CREATE_ENDPOINT,
|
||||
session=session,
|
||||
json_body=body,
|
||||
timeout_seconds=180,
|
||||
)
|
||||
|
||||
async def poll_video_status_async(
|
||||
self,
|
||||
task_id: str,
|
||||
session: aiohttp.ClientSession,
|
||||
poll_interval: int = 5,
|
||||
timeout: int = 900,
|
||||
progress_callback: Optional[Callable[[int, str, float], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
endpoint = self.STATUS_ENDPOINT.format(task_id=task_id)
|
||||
start = time.time()
|
||||
interval = max(1, int(poll_interval))
|
||||
|
||||
await interruptible_sleep(interval)
|
||||
|
||||
while True:
|
||||
data = await self._request_json_with_retry(
|
||||
"GET",
|
||||
endpoint,
|
||||
session=session,
|
||||
task_id=task_id,
|
||||
timeout_seconds=60,
|
||||
)
|
||||
|
||||
status = extract_status(data)
|
||||
progress = extract_progress(data)
|
||||
elapsed = time.time() - start
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(progress, status, elapsed)
|
||||
|
||||
if status in self.SUCCESS_STATUSES or is_success_status(status):
|
||||
return data
|
||||
|
||||
if status in self.FAILURE_STATUSES or is_failure_status(status, data):
|
||||
raise RuntimeError(self._format_task_failure(task_id, data))
|
||||
|
||||
if elapsed >= timeout:
|
||||
raise TimeoutError(
|
||||
"Grok Video 任务轮询超时;任务未被标记为失败,可用 task_id 继续查询。\n"
|
||||
f"endpoint: {endpoint}\n"
|
||||
f"task_id: {task_id}\n"
|
||||
f"status: {status or 'unknown'}\n"
|
||||
f"timeout: {timeout}s"
|
||||
)
|
||||
|
||||
await interruptible_sleep(min(interval, max(0.0, timeout - elapsed)))
|
||||
|
||||
async def _download_url_to_file(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
session: aiohttp.ClientSession,
|
||||
max_retries: int = 3,
|
||||
) -> str:
|
||||
timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=900)
|
||||
last_status = 0
|
||||
last_text = ""
|
||||
headers = None
|
||||
resolved_url = url
|
||||
|
||||
if url.startswith("data:"):
|
||||
if "," not in url:
|
||||
raise RuntimeError("Grok Video 下载失败:data URL 格式无效。")
|
||||
_, b64_data = url.split(",", 1)
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(base64.b64decode(b64_data))
|
||||
if not os.path.isfile(save_path) or os.path.getsize(save_path) <= 0:
|
||||
raise RuntimeError("Grok Video 下载失败:保存后的文件为空。")
|
||||
return save_path
|
||||
|
||||
if url.startswith("/"):
|
||||
resolved_url = f"{self.base_url}{url}"
|
||||
headers = self.get_headers(use_bearer_token=True)
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
check_interrupt()
|
||||
async with session.get(
|
||||
resolved_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
) as response:
|
||||
if 200 <= response.status < 300:
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
with open(save_path, "wb") as f:
|
||||
async for chunk in response.content.iter_chunked(1024 * 1024):
|
||||
check_interrupt()
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
if not os.path.isfile(save_path) or os.path.getsize(save_path) <= 0:
|
||||
raise RuntimeError("Grok Video 下载失败:保存后的文件为空。")
|
||||
return save_path
|
||||
|
||||
last_status = response.status
|
||||
last_text = await response.text()
|
||||
if response.status not in RETRYABLE_STATUS_CODES or attempt >= max_retries:
|
||||
break
|
||||
|
||||
delay = min(2 ** attempt, 8)
|
||||
print(f"Grok Video:下载重试 {attempt + 1}/{max_retries},{delay}s 后继续...")
|
||||
await interruptible_sleep(delay)
|
||||
|
||||
raise RuntimeError(self._format_http_error("download_url", last_status, last_text))
|
||||
|
||||
async def download_video_async(
|
||||
self,
|
||||
task_id: str,
|
||||
save_path: str,
|
||||
session: aiohttp.ClientSession,
|
||||
) -> str:
|
||||
endpoint = self.CONTENT_ENDPOINT.format(task_id=task_id)
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = self.get_headers(use_bearer_token=True)
|
||||
timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=900)
|
||||
|
||||
last_status = 0
|
||||
last_text = ""
|
||||
|
||||
for attempt in range(4):
|
||||
check_interrupt()
|
||||
async with session.get(url, headers=headers, timeout=timeout, allow_redirects=True) as response:
|
||||
if 200 <= response.status < 300:
|
||||
content_type = response.headers.get("Content-Type", "").lower()
|
||||
if "application/json" in content_type:
|
||||
data = await response.json(content_type=None)
|
||||
download_url = extract_video_url(data)
|
||||
if not download_url:
|
||||
raise RuntimeError(
|
||||
"Grok Video 下载失败:content 响应为 JSON,但未包含视频 URL。\n"
|
||||
f"endpoint: {endpoint}\n"
|
||||
f"task_id: {task_id}"
|
||||
)
|
||||
return await self._download_url_to_file(download_url, save_path, session)
|
||||
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
with open(save_path, "wb") as f:
|
||||
async for chunk in response.content.iter_chunked(1024 * 1024):
|
||||
check_interrupt()
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
if not os.path.isfile(save_path) or os.path.getsize(save_path) <= 0:
|
||||
raise RuntimeError(
|
||||
"Grok Video 下载失败:保存后的文件为空。\n"
|
||||
f"endpoint: {endpoint}\n"
|
||||
f"task_id: {task_id}"
|
||||
)
|
||||
return save_path
|
||||
|
||||
last_status = response.status
|
||||
last_text = await response.text()
|
||||
if response.status not in RETRYABLE_STATUS_CODES or attempt >= 3:
|
||||
break
|
||||
|
||||
delay = min(2 ** attempt, 8)
|
||||
print(f"Grok Video:content 下载重试 {attempt + 1}/3,{delay}s 后继续...")
|
||||
await interruptible_sleep(delay)
|
||||
|
||||
raise RuntimeError(self._format_http_error(endpoint, last_status, last_text, task_id=task_id))
|
||||
|
||||
def generate_video_sync(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
aspect_ratio: str,
|
||||
seconds: int,
|
||||
quality: str,
|
||||
images: Optional[List[str]],
|
||||
output_dir: Optional[str] = None,
|
||||
save_path: Optional[str] = None,
|
||||
poll_interval: int = 5,
|
||||
timeout: int = 900,
|
||||
progress_callback: Optional[Callable[[int, str, float], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
async def _run():
|
||||
async with self._make_session() as session:
|
||||
body = self.build_video_body(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
aspect_ratio=aspect_ratio,
|
||||
seconds=seconds,
|
||||
quality=quality,
|
||||
images=images,
|
||||
)
|
||||
|
||||
create_response = await self.create_video_async(body, session)
|
||||
task_id = self._extract_task_id(create_response) or ""
|
||||
if not task_id:
|
||||
raise RuntimeError(
|
||||
"Grok Video 未返回任务 ID。\n"
|
||||
f"endpoint: {self.CREATE_ENDPOINT}\n"
|
||||
f"response: {json.dumps(create_response, ensure_ascii=False)[:1200]}"
|
||||
)
|
||||
|
||||
print(f"Grok Video:任务已提交,任务ID:{task_id}")
|
||||
print("Grok Video:视频生成中...")
|
||||
status_response = await self.poll_video_status_async(
|
||||
task_id=task_id,
|
||||
session=session,
|
||||
poll_interval=poll_interval,
|
||||
timeout=timeout,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
video_url = extract_video_url(status_response)
|
||||
print("Grok Video:视频生成完成,正在下载...")
|
||||
if save_path is None:
|
||||
resolved_output_dir = output_dir or os.getcwd()
|
||||
os.makedirs(resolved_output_dir, exist_ok=True)
|
||||
target_path = os.path.join(
|
||||
resolved_output_dir,
|
||||
f"{self._safe_task_filename(task_id)}.mp4",
|
||||
)
|
||||
else:
|
||||
target_path = save_path
|
||||
|
||||
if video_url:
|
||||
video_path = await self._download_url_to_file(video_url, target_path, session)
|
||||
else:
|
||||
video_path = await self.download_video_async(task_id, target_path, session)
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": extract_status(status_response),
|
||||
"video_path": video_path,
|
||||
"raw_json": {
|
||||
"create": create_response,
|
||||
"status": status_response,
|
||||
},
|
||||
}
|
||||
|
||||
return self.run_async_in_thread(_run())
|
||||
@@ -0,0 +1,542 @@
|
||||
"""
|
||||
new-api Veo 3.1 video client.
|
||||
|
||||
Implements the OpenAI-compatible /v1/videos task flow:
|
||||
submit, poll, and stream-download video content.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .base_client import BaseAPIClient
|
||||
from ..utils.config import get_api_base_url, get_api_key_or_raise
|
||||
|
||||
|
||||
class NewAPIVeoClient(BaseAPIClient):
|
||||
CREATE_ENDPOINT = "/v1/videos"
|
||||
STATUS_ENDPOINT = "/v1/videos/{task_id}"
|
||||
CONTENT_ENDPOINT = "/v1/videos/{task_id}/content"
|
||||
|
||||
RETRYABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
|
||||
COMPLETED_STATUSES = {"completed", "succeeded", "success", "done"}
|
||||
FAILED_STATUSES = {"failed", "error", "cancelled", "canceled"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
):
|
||||
api_key = get_api_key_or_raise("O1KEY_API_KEY")
|
||||
resolved_base_url = (base_url or "").strip() or get_api_base_url()
|
||||
super().__init__(base_url=resolved_base_url.rstrip("/"), api_key=api_key)
|
||||
|
||||
def get_endpoint(self, **kwargs) -> str:
|
||||
return self.CREATE_ENDPOINT
|
||||
|
||||
def build_request_body(self, **kwargs) -> Dict[str, Any]:
|
||||
return self._build_video_body(**kwargs)
|
||||
|
||||
def parse_response(self, response: Dict[str, Any]) -> Any:
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _build_video_body(
|
||||
prompt: str,
|
||||
model: str,
|
||||
duration: int,
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
negative_prompt: str = "",
|
||||
generate_audio: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
metadata: Dict[str, Any] = {
|
||||
"aspectRatio": aspect_ratio,
|
||||
"resolution": resolution,
|
||||
"generateAudio": bool(generate_audio),
|
||||
}
|
||||
|
||||
negative_prompt = (negative_prompt or "").strip()
|
||||
if negative_prompt:
|
||||
metadata["negativePrompt"] = negative_prompt
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"duration": int(duration),
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def _print_request_body(body: Dict[str, Any], image_bytes: Optional[bytes] = None) -> None:
|
||||
log_body = dict(body)
|
||||
if image_bytes is not None:
|
||||
log_body["input_reference"] = f"<PNG bytes: {len(image_bytes)}>"
|
||||
print(
|
||||
"NewAPI Veo request body:\n"
|
||||
f"{json.dumps(log_body, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_task_filename(task_id: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", task_id).strip("._")
|
||||
return safe or "newapi_veo"
|
||||
|
||||
@staticmethod
|
||||
def _extract_task_id(data: Dict[str, Any]) -> Optional[str]:
|
||||
for key in ("id", "task_id", "video_id"):
|
||||
value = data.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
|
||||
nested = data.get("data")
|
||||
if isinstance(nested, dict):
|
||||
for key in ("id", "task_id", "video_id"):
|
||||
value = nested.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_status(data: Dict[str, Any]) -> str:
|
||||
for key in ("status", "state", "task_status"):
|
||||
value = data.get(key)
|
||||
if value:
|
||||
return str(value).lower()
|
||||
|
||||
nested = data.get("data")
|
||||
if isinstance(nested, dict):
|
||||
for key in ("status", "state", "task_status"):
|
||||
value = nested.get(key)
|
||||
if value:
|
||||
return str(value).lower()
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _extract_progress(data: Dict[str, Any]) -> int:
|
||||
progress = data.get("progress")
|
||||
if progress is None and isinstance(data.get("data"), dict):
|
||||
progress = data["data"].get("progress")
|
||||
|
||||
if isinstance(progress, str):
|
||||
progress = progress.rstrip("%").strip()
|
||||
try:
|
||||
return int(float(progress))
|
||||
except ValueError:
|
||||
return 0
|
||||
if isinstance(progress, (int, float)):
|
||||
return int(progress)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _format_http_error(
|
||||
cls,
|
||||
endpoint: str,
|
||||
status: int,
|
||||
error_text: str,
|
||||
task_id: Optional[str] = None,
|
||||
) -> str:
|
||||
code = ""
|
||||
message = error_text
|
||||
try:
|
||||
payload = json.loads(error_text)
|
||||
error = payload.get("error", payload)
|
||||
if isinstance(error, dict):
|
||||
code = str(error.get("code") or error.get("type") or "")
|
||||
message = str(error.get("message") or payload.get("message") or error_text)
|
||||
elif error is not None:
|
||||
message = str(error)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
message = (message or "").strip()
|
||||
if len(message) > 1200:
|
||||
message = message[:1200] + "...(truncated)"
|
||||
|
||||
if status in (401, 403):
|
||||
hint = "凭证或分组权限问题,请检查 new-api token、模型分组或渠道权限。"
|
||||
elif status == 429:
|
||||
hint = "频率或额度限制,请稍后重试或检查 new-api 额度。"
|
||||
elif status in (502, 503, 504):
|
||||
hint = "上游服务暂时不可用或超时,请稍后用 task_id 继续查询。"
|
||||
elif status == 400:
|
||||
hint = "请求参数错误,请检查 model、duration、metadata 和图片输入。"
|
||||
else:
|
||||
hint = "new-api 视频请求失败。"
|
||||
|
||||
parts = [
|
||||
hint,
|
||||
f"endpoint: {endpoint}",
|
||||
f"http_status: {status}",
|
||||
]
|
||||
if task_id:
|
||||
parts.append(f"task_id: {task_id}")
|
||||
if code:
|
||||
parts.append(f"error_code: {code}")
|
||||
if message:
|
||||
parts.append(f"message: {message}")
|
||||
return "\n".join(parts)
|
||||
|
||||
@classmethod
|
||||
def _format_task_failure(cls, task_id: str, data: Dict[str, Any]) -> str:
|
||||
error = data.get("error")
|
||||
if error is None and isinstance(data.get("data"), dict):
|
||||
error = data["data"].get("error")
|
||||
|
||||
if isinstance(error, dict):
|
||||
code = error.get("code") or error.get("type") or ""
|
||||
message = error.get("message") or json.dumps(error, ensure_ascii=False)
|
||||
else:
|
||||
code = ""
|
||||
message = str(error or "未知错误")
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
"Veo 视频任务失败。",
|
||||
f"endpoint: {cls.STATUS_ENDPOINT.format(task_id=task_id)}",
|
||||
f"task_id: {task_id}",
|
||||
f"error_code: {code}",
|
||||
f"message: {message}",
|
||||
]
|
||||
)
|
||||
|
||||
async def create_video_async(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
duration: int,
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
negative_prompt: str = "",
|
||||
generate_audio: bool = True,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
session: Optional[aiohttp.ClientSession] = None,
|
||||
) -> Dict[str, Any]:
|
||||
body = self._build_video_body(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
duration=duration,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
negative_prompt=negative_prompt,
|
||||
generate_audio=generate_audio,
|
||||
)
|
||||
|
||||
url = f"{self.base_url}{self.CREATE_ENDPOINT}"
|
||||
close_session = False
|
||||
if session is None:
|
||||
session = self._make_session()
|
||||
close_session = True
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=120, connect=30, sock_read=120)
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
if image_bytes is not None:
|
||||
if len(image_bytes) > self.max_request_size:
|
||||
raise ValueError(
|
||||
f"输入图片过大,超过 {self.max_request_size / 1024 / 1024:.0f}MB 限制"
|
||||
)
|
||||
|
||||
self._print_request_body(body, image_bytes=image_bytes)
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("model", body["model"])
|
||||
form.add_field("prompt", body["prompt"])
|
||||
form.add_field("duration", str(body["duration"]))
|
||||
form.add_field("metadata", json.dumps(body["metadata"], ensure_ascii=False))
|
||||
form.add_field(
|
||||
"input_reference",
|
||||
image_bytes,
|
||||
filename="input_reference.png",
|
||||
content_type="image/png",
|
||||
)
|
||||
request_kwargs = {"data": form, "headers": headers}
|
||||
print(
|
||||
"NewAPI Veo: POST /v1/videos multipart "
|
||||
f"| model={model} | duration={duration}s | {resolution} {aspect_ratio}"
|
||||
)
|
||||
else:
|
||||
self._print_request_body(body)
|
||||
headers["Content-Type"] = "application/json"
|
||||
request_kwargs = {"json": body, "headers": headers}
|
||||
print(
|
||||
"NewAPI Veo: POST /v1/videos json "
|
||||
f"| model={model} | duration={duration}s | {resolution} {aspect_ratio}"
|
||||
)
|
||||
|
||||
async with session.post(url, timeout=timeout, **request_kwargs) as response:
|
||||
if response.status >= 300:
|
||||
error_text = await response.text()
|
||||
raise RuntimeError(
|
||||
self._format_http_error(
|
||||
self.CREATE_ENDPOINT,
|
||||
response.status,
|
||||
error_text,
|
||||
)
|
||||
)
|
||||
return await response.json()
|
||||
finally:
|
||||
if close_session:
|
||||
await session.close()
|
||||
|
||||
async def _get_json_with_retry(
|
||||
self,
|
||||
endpoint: str,
|
||||
session: aiohttp.ClientSession,
|
||||
task_id: Optional[str] = None,
|
||||
max_retries: int = 3,
|
||||
) -> Dict[str, Any]:
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = self.get_headers(use_bearer_token=True)
|
||||
timeout = aiohttp.ClientTimeout(total=60, connect=30, sock_read=60)
|
||||
|
||||
last_error = ""
|
||||
last_status = 0
|
||||
for attempt in range(max_retries + 1):
|
||||
async with session.get(url, headers=headers, timeout=timeout) as response:
|
||||
if response.status < 300:
|
||||
return await response.json()
|
||||
|
||||
last_status = response.status
|
||||
last_error = await response.text()
|
||||
if response.status not in self.RETRYABLE_STATUS_CODES or attempt >= max_retries:
|
||||
break
|
||||
|
||||
await asyncio.sleep(min(2 ** attempt, 8))
|
||||
|
||||
raise RuntimeError(
|
||||
self._format_http_error(endpoint, last_status, last_error, task_id=task_id)
|
||||
)
|
||||
|
||||
async def poll_video_status_async(
|
||||
self,
|
||||
task_id: str,
|
||||
poll_interval: int = 5,
|
||||
timeout: int = 900,
|
||||
progress_callback: Optional[Callable[[int, str, float], None]] = None,
|
||||
session: Optional[aiohttp.ClientSession] = None,
|
||||
) -> Dict[str, Any]:
|
||||
endpoint = self.STATUS_ENDPOINT.format(task_id=task_id)
|
||||
close_session = False
|
||||
if session is None:
|
||||
session = self._make_session()
|
||||
close_session = True
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
poll_interval = max(1, int(poll_interval))
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
while True:
|
||||
data = await self._get_json_with_retry(endpoint, session, task_id=task_id)
|
||||
status = self._extract_status(data)
|
||||
elapsed = time.time() - start
|
||||
progress = self._extract_progress(data)
|
||||
|
||||
if status == "unknown":
|
||||
print(
|
||||
"NewAPI Veo status response did not include a recognized status field:\n"
|
||||
f"{json.dumps(data, ensure_ascii=False, indent=2)[:1200]}"
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(progress, status, elapsed)
|
||||
|
||||
if status in self.COMPLETED_STATUSES:
|
||||
return data
|
||||
|
||||
if status in self.FAILED_STATUSES:
|
||||
raise RuntimeError(self._format_task_failure(task_id, data))
|
||||
|
||||
if elapsed >= timeout:
|
||||
raise TimeoutError(
|
||||
"Veo 视频任务轮询超时;任务未被标记为失败,可用 task_id 继续查询。\n"
|
||||
f"endpoint: {endpoint}\n"
|
||||
f"task_id: {task_id}\n"
|
||||
f"status: {status}\n"
|
||||
f"timeout: {timeout}s"
|
||||
)
|
||||
|
||||
remaining = max(0.0, timeout - elapsed)
|
||||
await asyncio.sleep(min(poll_interval, remaining))
|
||||
finally:
|
||||
if close_session:
|
||||
await session.close()
|
||||
|
||||
async def _download_url_to_file(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
session: aiohttp.ClientSession,
|
||||
max_retries: int = 3,
|
||||
) -> None:
|
||||
timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=900)
|
||||
last_status = 0
|
||||
last_error = ""
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
async with session.get(url, timeout=timeout, allow_redirects=True) as response:
|
||||
if response.status < 300:
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
with open(save_path, "wb") as f:
|
||||
async for chunk in response.content.iter_chunked(1024 * 1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return
|
||||
|
||||
last_status = response.status
|
||||
last_error = await response.text()
|
||||
if response.status not in self.RETRYABLE_STATUS_CODES or attempt >= max_retries:
|
||||
break
|
||||
|
||||
await asyncio.sleep(min(2 ** attempt, 8))
|
||||
|
||||
raise RuntimeError(
|
||||
self._format_http_error("download_url", last_status, last_error)
|
||||
)
|
||||
|
||||
async def download_video_async(
|
||||
self,
|
||||
task_id: str,
|
||||
save_path: str,
|
||||
session: Optional[aiohttp.ClientSession] = None,
|
||||
) -> str:
|
||||
endpoint = self.CONTENT_ENDPOINT.format(task_id=task_id)
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = self.get_headers(use_bearer_token=True)
|
||||
timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=900)
|
||||
|
||||
close_session = False
|
||||
if session is None:
|
||||
session = self._make_session()
|
||||
close_session = True
|
||||
|
||||
try:
|
||||
last_status = 0
|
||||
last_error = ""
|
||||
for attempt in range(4):
|
||||
async with session.get(url, headers=headers, timeout=timeout, allow_redirects=True) as response:
|
||||
if response.status < 300:
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "application/json" in content_type.lower():
|
||||
data = await response.json()
|
||||
nested = data.get("data") if isinstance(data.get("data"), dict) else {}
|
||||
download_url = (
|
||||
data.get("url")
|
||||
or data.get("download_url")
|
||||
or nested.get("url")
|
||||
or nested.get("download_url")
|
||||
)
|
||||
if not download_url:
|
||||
raise RuntimeError(
|
||||
"视频下载失败: content 响应为 JSON,但未包含 url/download_url。\n"
|
||||
f"endpoint: {endpoint}\n"
|
||||
f"task_id: {task_id}"
|
||||
)
|
||||
await self._download_url_to_file(download_url, save_path, session)
|
||||
else:
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
with open(save_path, "wb") as f:
|
||||
async for chunk in response.content.iter_chunked(1024 * 1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
if not os.path.isfile(save_path) or os.path.getsize(save_path) <= 0:
|
||||
raise RuntimeError(
|
||||
"视频下载失败: 保存后的文件为空。\n"
|
||||
f"endpoint: {endpoint}\n"
|
||||
f"task_id: {task_id}"
|
||||
)
|
||||
return save_path
|
||||
|
||||
last_status = response.status
|
||||
last_error = await response.text()
|
||||
if response.status not in self.RETRYABLE_STATUS_CODES or attempt >= 3:
|
||||
break
|
||||
|
||||
await asyncio.sleep(min(2 ** attempt, 8))
|
||||
|
||||
raise RuntimeError(
|
||||
self._format_http_error(endpoint, last_status, last_error, task_id=task_id)
|
||||
)
|
||||
finally:
|
||||
if close_session:
|
||||
await session.close()
|
||||
|
||||
def generate_video_sync(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
duration: int,
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
output_dir: str,
|
||||
negative_prompt: str = "",
|
||||
generate_audio: bool = True,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
poll_interval: int = 5,
|
||||
timeout: int = 900,
|
||||
reuse_task_id: str = "",
|
||||
progress_callback: Optional[Callable[[int, str, float], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
async def _run():
|
||||
async with self._make_session() as session:
|
||||
create_response: Dict[str, Any] = {}
|
||||
task_id = (reuse_task_id or "").strip()
|
||||
if task_id:
|
||||
print(f"NewAPI Veo: reuse task_id={task_id}")
|
||||
else:
|
||||
create_response = await self.create_video_async(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
duration=duration,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
negative_prompt=negative_prompt,
|
||||
generate_audio=generate_audio,
|
||||
image_bytes=image_bytes,
|
||||
session=session,
|
||||
)
|
||||
task_id = self._extract_task_id(create_response) or ""
|
||||
if not task_id:
|
||||
raise RuntimeError(
|
||||
"new-api 未返回视频任务 ID。\n"
|
||||
f"endpoint: {self.CREATE_ENDPOINT}\n"
|
||||
f"response: {json.dumps(create_response, ensure_ascii=False)[:1200]}"
|
||||
)
|
||||
|
||||
status_response = await self.poll_video_status_async(
|
||||
task_id=task_id,
|
||||
poll_interval=poll_interval,
|
||||
timeout=timeout,
|
||||
progress_callback=progress_callback,
|
||||
session=session,
|
||||
)
|
||||
status = self._extract_status(status_response)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
filename = f"{self._safe_task_filename(task_id)}.mp4"
|
||||
save_path = os.path.join(output_dir, filename)
|
||||
video_path = await self.download_video_async(
|
||||
task_id=task_id,
|
||||
save_path=save_path,
|
||||
session=session,
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"video_path": video_path,
|
||||
"raw_json": {
|
||||
"create": create_response,
|
||||
"status": status_response,
|
||||
},
|
||||
}
|
||||
|
||||
return self.run_async_in_thread(_run())
|
||||
Reference in New Issue
Block a user