Files
comfyui_o1key/clients/grok_video_client.py
T

484 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 Videocontent 下载重试 {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())