fix: download xai video outputs reliably

This commit is contained in:
2026-06-03 23:21:22 +08:00
parent b1aab451ef
commit 3f216727bb
3 changed files with 40 additions and 5 deletions

View File

@@ -159,7 +159,7 @@
- `IMAGE_FALLBACK_ENABLED` / `IMAGE_FALLBACK_MODEL`:图片主模型故障兜底;当前允许在 `gpt-image-2` 超时、429、5xx 或网络错误时临时使用 `gemini-3-pro-image-preview`400/401/403/404 和参数错误不兜底
- `IMAGE_CIRCUIT_FAILURE_THRESHOLD` / `IMAGE_CIRCUIT_COOLDOWN_SECONDS`:短时熔断配置,默认 `gpt-image-2` 连续 2 次上游类失败后 600 秒内直接走 Gemini 兜底;成功恢复后自动清空失败计数
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名;主体 6 视图在转换层默认自动使用 `gpt-image-2`,同一套图内一旦触发 Gemini 兜底,后续视图沿用 Gemini避免一张张等待主模型超时用户显式选择 GPT 或 Gemini 时,`image_model_preference` 会让主体套图只走所选模型
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图或生视频 MP4 下载报 DNS / ConnectError / SSL 握手异常,可在本地 `api/.env` 配置后重启后端。本地 Docker 使用 `deploy/.env.local`,宿主机代理要写成 `http://host.docker.internal:端口`,不能写容器内的 `127.0.0.1``/health` 只回传是否配置代理,不回传代理地址。
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;生产云端固定使用 cookies 文件 `/run/secrets/tiktok_cookies.txt`(宿主机 `./secrets/tiktok_cookies.txt` 挂载进容器),本地开发可临时用浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
- `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`;旧环境若写 `minimax` 会被忽略
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`

View File

@@ -9032,6 +9032,25 @@ def _video_public_error(raw: object) -> str:
text = str(raw or "").strip()
lower = text.lower()
if any(token in lower for token in (
"name or service not known",
"temporary failure in name resolution",
"nodename nor servname",
"connection refused",
"network is unreachable",
"connecterror",
"connecttimeout",
"readtimeout",
"ssl:",
"_ssl.c",
"handshake",
"unexpected_eof",
"eof occurred",
"网络",
"dns",
)):
return "视频生成失败:服务器连接视频模型网关异常,请稍后重试;如果连续失败,请联系管理员检查视频网关网络。"
if any(token.lower() in lower for token in (
"InputImageSensitiveContentDetected.PrivacyInformation".lower(),
"privacyinformation",
@@ -9116,15 +9135,31 @@ def _video_create_failure_message(create_errors: list[str]) -> str:
def download_generated_video(client, base: str, headers: dict, provider_id: str, direct_url: str, out_mp4: Path, model: str | None = None) -> None:
last_error: Exception | None = None
for attempt in range(4):
try:
_download_generated_video_once(client, base, headers, provider_id, direct_url, out_mp4, model)
return
except Exception as e:
last_error = e
if attempt >= 3:
break
time.sleep(2 + attempt * 3)
raise last_error or RuntimeError("视频生成完成但下载失败")
def _download_generated_video_once(client, base: str, headers: dict, provider_id: str, direct_url: str, out_mp4: Path, model: str | None = None) -> None:
if direct_url:
url = direct_url if direct_url.startswith("http") else f"{base}{direct_url if direct_url.startswith('/') else '/' + direct_url}"
r = client.get(url, headers=headers if url.startswith(base) else None)
r = client.get(url, headers=headers if url.startswith(base) else None, follow_redirects=True)
else:
content_path = video_content_path(model)
if not content_path:
raise RuntimeError("视频生成完成但未返回可下载地址")
r = client.get(f"{base}{video_path(content_path, id=provider_id)}", headers=headers)
r = client.get(f"{base}{video_path(content_path, id=provider_id)}", headers=headers, follow_redirects=True)
r.raise_for_status()
if not r.content:
raise RuntimeError("视频生成完成但下载内容为空")
out_mp4.write_bytes(r.content)
@@ -9285,7 +9320,7 @@ def render_storyboard_video(
prepare_video_reference(product_ref_path, product_img)
prepared_product_imgs.append(product_img)
update_generated_video(job_id, local_id, status="in_progress", progress=5, queue_message="准备素材…")
with httpx.Client(timeout=120) as client:
with ai_http_client(timeout=300) as client:
payload = {"model": model, "prompt": prompt, "size": size, "resolution": resolution}
payload[VIDEO_DURATION_FIELD] = seconds
create = None

View File

@@ -1333,7 +1333,7 @@ ProductRefStateItem {
</header>
<div class="body">
<p><strong>问题:</strong>SKG xAI 网关 <code>https://ai.skg.com/ezlink/xai</code> 已确认可用 <code>grok-imagine-video</code> 文生视频,但项目只把 Seedance 暴露给画布,后端也按单一视频网关处理,无法同时保留 Seedance 并新增 xAI。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增 <code>xai</code> / <code>grok-imagine-video</code> 视频模型别名、<code>XAI_VIDEO_API_BASE_URL</code> / <code>XAI_VIDEO_API_KEY</code> / <code>XAI_VIDEO_CREATE_PATH</code> / <code>XAI_VIDEO_STATUS_PATH</code> 配置,按模型分流到 <code>/v1/videos/generations</code><code>/v1/videos/{id}</code>;创建时识别 xAI 的 <code>request_id</code>,轮询完成时读取 <code>video.url</code> 并下载 MP4。纯文生视频不会把系统空白帧误传为参考图图生视频会把用户上传首帧作为 <code>image</code> 传入。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增 <code>xai</code> / <code>grok-imagine-video</code> 视频模型别名、<code>XAI_VIDEO_API_BASE_URL</code> / <code>XAI_VIDEO_API_KEY</code> / <code>XAI_VIDEO_CREATE_PATH</code> / <code>XAI_VIDEO_STATUS_PATH</code> 配置,按模型分流到 <code>/v1/videos/generations</code><code>/v1/videos/{id}</code>;创建时识别 xAI 的 <code>request_id</code>,轮询完成时读取 <code>video.url</code> 并下载 MP4。视频创建、轮询和 MP4 下载统一复用 <code>ai_http_client()</code>,可走 <code>AI_HTTP_PROXY</code>MP4 下载会跟随重定向并重试,避免 <code>vidgen.x.ai</code> TLS 握手偶发失败时直接丢结果。纯文生视频不会把系统空白帧误传为参考图;图生视频会把用户上传首帧作为 <code>image</code> 传入。</p>
<p><strong>前端 / 配置:</strong><code>web/canvas-app/src/config/models.js</code> 新增默认不可用的 <code>xai</code> 模型,<code>web/canvas-app/src/stores/pinia/models.js</code> 改为接受后端 <code>/health</code> 返回的可用视频模型,不再硬编码只保留 Seedance。<code>api/.env.example</code><code>deploy/.env.local.example</code><code>deploy/.env.production.example</code> 增加 xAI 私有 key 配置位,真实 key 只填本地或服务器私有 env。</p>
</div>
</article>