From 549082ace3a38307f8971e47bfda8549fad278a6 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 4 Jun 2026 14:17:12 +0800 Subject: [PATCH] fix: retry transient xai video creation failures --- RULES.md | 1 + api/.env.example | 2 + api/main.py | 89 ++++++++++++++++++++++++++++------ deploy/.env.local.example | 2 + deploy/.env.production.example | 2 + docs/source-analysis.html | 16 +++++- 6 files changed, 95 insertions(+), 17 deletions(-) diff --git a/RULES.md b/RULES.md index b6ff9b8..1bfd54e 100644 --- a/RULES.md +++ b/RULES.md @@ -168,6 +168,7 @@ - `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`:Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用 - `POE_API_KEY` / `VIDEO_API_KEY`:默认视频生成通道 Key,只能放本地环境变量;如果显式配置了 `VIDEO_API_BASE_URL`,必须同时配置 `VIDEO_API_KEY` 才会在 `/health` 暴露该默认视频通道,不能用通用 `LLM_API_KEY` 冒充视频 key。 - `XAI_VIDEO_API_BASE_URL` / `XAI_VIDEO_API_KEY` / `VIDEO_MODEL_XAI`:xAI / Grok Imagine Video 独立视频通道;默认 base 为 `https://ai.skg.com/ezlink/xai`,模型为 `grok-imagine-video`,真实 key 只放本地 `api/.env`、本地 Docker `deploy/.env.local` 或服务器 `deploy/.env.production`,不入库。未配置 `XAI_VIDEO_API_KEY` 时 `/health` 会标记 xAI 视频不可用,画布不显示该模型;已配置时即使默认 Doubao/Seedance 视频 key 为空,也可以独立显示和生成 Grok Imagine Video。 +- `VIDEO_CREATE_RETRY_ATTEMPTS` / `VIDEO_CREATE_RETRY_BACKOFF_SECONDS`:视频创建请求的瞬时错误重试配置,默认 Grok/xAI 创建阶段遇到连接重置、超时、429 或 5xx 时最多尝试 3 次,基础退避 2 秒;400/401/403/404 等参数或权限错误不重试,避免掩盖真实配置问题。 - `PASSWORD_AUTH_ENABLED`:生产密码登录总开关;当前固定为 `false`,只允许飞书免登录。若应急恢复密码入口,必须显式改成 `true` 并重启 API。 - `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。当前密码入口被 `PASSWORD_AUTH_ENABLED=false` 禁用;即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。 - `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。 diff --git a/api/.env.example b/api/.env.example index 91654b4..2293011 100644 --- a/api/.env.example +++ b/api/.env.example @@ -115,6 +115,8 @@ VIDEO_STATUS_PATH=/videos/{id} VIDEO_CONTENT_PATH=/videos/{id}/content VIDEO_DURATION_FIELD=seconds VIDEO_POLL_TIMEOUT_SECONDS=900 +VIDEO_CREATE_RETRY_ATTEMPTS=3 +VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2 # 工作目录 KEYFRAME_COUNT=12 diff --git a/api/main.py b/api/main.py index 3831680..8dca8d1 100644 --- a/api/main.py +++ b/api/main.py @@ -451,6 +451,8 @@ VIDEO_STATUS_PATH = os.getenv("VIDEO_STATUS_PATH", DEFAULT_VIDEO_STATUS_PATH).st VIDEO_CONTENT_PATH = os.getenv("VIDEO_CONTENT_PATH", DEFAULT_VIDEO_CONTENT_PATH).strip() or DEFAULT_VIDEO_CONTENT_PATH VIDEO_DURATION_FIELD = os.getenv("VIDEO_DURATION_FIELD", "seconds").strip() or "seconds" VIDEO_POLL_TIMEOUT_SECONDS = max(60, int(os.getenv("VIDEO_POLL_TIMEOUT_SECONDS", "900"))) +VIDEO_CREATE_RETRY_ATTEMPTS = max(1, int(os.getenv("VIDEO_CREATE_RETRY_ATTEMPTS", "3"))) +VIDEO_CREATE_RETRY_BACKOFF_SECONDS = max(0.5, float(os.getenv("VIDEO_CREATE_RETRY_BACKOFF_SECONDS", "2"))) FFMPEG_BIN = os.getenv("FFMPEG_BIN", "").strip() FFPROBE_BIN = os.getenv("FFPROBE_BIN", "").strip() LOCAL_FFMPEG_CANDIDATES = [ @@ -6729,6 +6731,8 @@ def health() -> dict: "video_base_url": video_api_base(), "video_configured": bool(video_api_key()), "video_create_paths": VIDEO_CREATE_PATHS, + "video_create_retry_attempts": VIDEO_CREATE_RETRY_ATTEMPTS, + "video_create_retry_backoff_seconds": VIDEO_CREATE_RETRY_BACKOFF_SECONDS, "xai_video_model": XAI_VIDEO_MODEL, "xai_video_base_url": XAI_VIDEO_API_BASE_URL, "xai_video_configured": bool(video_api_key(XAI_VIDEO_MODEL)), @@ -9067,6 +9071,9 @@ def _video_public_error(raw: object) -> str: "connecterror", "connecttimeout", "readtimeout", + "connection reset", + "connection aborted", + "remote protocol error", "ssl:", "_ssl.c", "handshake", @@ -9124,6 +9131,19 @@ def _video_public_error(raw: object) -> str: if any(token in lower for token in ("timeout", "timed out", "readtimeout", "connecttimeout", "超时")): return "视频生成失败:视频模型响应超时,可能是上游繁忙或网络不稳定。请稍后重试,或缩短时长后再生成。" + if any(token in lower for token in ( + "http 500", + "http 502", + "http 503", + "http 504", + "internal server error", + "bad gateway", + "service unavailable", + "gateway timeout", + "server error", + )): + return "视频生成失败:视频模型上游服务暂时异常,系统已自动重试但仍未成功。请稍后重新生成;如果持续出现,请联系管理员检查视频网关。" + if any(token in lower for token in ( "name or service not known", "temporary failure in name resolution", @@ -9131,6 +9151,9 @@ def _video_public_error(raw: object) -> str: "connection refused", "network is unreachable", "connecterror", + "connection reset", + "connection aborted", + "remote protocol error", "ssl:", "网络", "dns", @@ -9309,6 +9332,21 @@ def submit_video_create( ) +_VIDEO_CREATE_RETRY_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504} + + +def _video_create_attempts(model: str | None) -> int: + return VIDEO_CREATE_RETRY_ATTEMPTS if video_uses_xai(model) else 1 + + +def _video_create_retry_delay(attempt: int) -> float: + return min(20.0, VIDEO_CREATE_RETRY_BACKOFF_SECONDS * (2 ** max(0, attempt - 1))) + + +def _video_create_transport_error(exc: Exception) -> bool: + return isinstance(exc, (httpx.TransportError, httpx.TimeoutException)) + + def render_storyboard_video( job_id: str, local_id: str, @@ -9352,22 +9390,43 @@ def render_storyboard_video( create = None create_errors: list[str] = [] for create_path in video_create_paths(model): - resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs, primary_role) - if video_uses_ark(model) and source_ref and resp.status_code in {400, 422}: - create_errors.append(f"{video_path(create_path)} + reference_video -> HTTP {resp.status_code}: {resp.text[:700]}") - resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs, primary_role) - if video_uses_ark(model) and prepared_last_img and resp.status_code in {400, 422}: - create_errors.append(f"{video_path(create_path)} + last_frame -> HTTP {resp.status_code}: {resp.text[:700]}") - resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_imgs, primary_role) - if video_uses_ark(model) and prepared_product_imgs and resp.status_code in {400, 422}: - create_errors.append(f"{video_path(create_path)} + product_reference -> HTTP {resp.status_code}: {resp.text[:700]}") - resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, None, primary_role) - if resp.status_code < 400: - create = resp + path = video_path(create_path) + url = f"{base}{path}" + attempts = _video_create_attempts(model) + for attempt in range(1, attempts + 1): + try: + resp = submit_video_create(client, url, headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs, primary_role) + except Exception as exc: + create_errors.append(f"{path} attempt {attempt}/{attempts} -> {exc.__class__.__name__}: {str(exc)[:700]}") + if attempt < attempts and _video_create_transport_error(exc): + delay = _video_create_retry_delay(attempt) + print(f"[video create retry] job={job_id} video={local_id} path={path} attempt={attempt}/{attempts} error={str(exc)[:300]} retry_in={delay:.1f}s", flush=True) + time.sleep(delay) + continue + raise + if video_uses_ark(model) and source_ref and resp.status_code in {400, 422}: + create_errors.append(f"{path} + reference_video -> HTTP {resp.status_code}: {resp.text[:700]}") + resp = submit_video_create(client, url, headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs, primary_role) + if video_uses_ark(model) and prepared_last_img and resp.status_code in {400, 422}: + create_errors.append(f"{path} + last_frame -> HTTP {resp.status_code}: {resp.text[:700]}") + resp = submit_video_create(client, url, headers, ref_img, payload, None, None, prepared_product_imgs, primary_role) + if video_uses_ark(model) and prepared_product_imgs and resp.status_code in {400, 422}: + create_errors.append(f"{path} + product_reference -> HTTP {resp.status_code}: {resp.text[:700]}") + resp = submit_video_create(client, url, headers, ref_img, payload, None, prepared_last_img, None, primary_role) + if resp.status_code < 400: + create = resp + break + create_errors.append(f"{path} attempt {attempt}/{attempts} -> HTTP {resp.status_code}: {resp.text[:700]}") + if resp.status_code in _VIDEO_CREATE_RETRY_STATUS_CODES and attempt < attempts: + delay = _video_create_retry_delay(attempt) + print(f"[video create retry] job={job_id} video={local_id} path={path} attempt={attempt}/{attempts} http={resp.status_code} retry_in={delay:.1f}s", flush=True) + time.sleep(delay) + continue + if resp.status_code not in {400, 404, 405}: + raise RuntimeError(_video_create_failure_message(create_errors)) + break + if create is not None: break - create_errors.append(f"{video_path(create_path)} -> HTTP {resp.status_code}: {resp.text[:700]}") - if resp.status_code not in {400, 404, 405}: - resp.raise_for_status() if create is None: print(f"[video create failed] job={job_id} video={local_id} errors={' | '.join(create_errors)[:1800]}", flush=True) raise RuntimeError(_video_create_failure_message(create_errors)) diff --git a/deploy/.env.local.example b/deploy/.env.local.example index 6a77223..1954273 100644 --- a/deploy/.env.local.example +++ b/deploy/.env.local.example @@ -82,6 +82,8 @@ VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id} VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content VIDEO_DURATION_FIELD=seconds VIDEO_POLL_TIMEOUT_SECONDS=900 +VIDEO_CREATE_RETRY_ATTEMPTS=3 +VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2 XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai XAI_VIDEO_API_KEY= XAI_VIDEO_CREATE_PATH=/v1/videos/generations diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 70e8b8c..d7dfabd 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -114,6 +114,8 @@ VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id} VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content VIDEO_DURATION_FIELD=seconds VIDEO_POLL_TIMEOUT_SECONDS=900 +VIDEO_CREATE_RETRY_ATTEMPTS=3 +VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2 XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai XAI_VIDEO_API_KEY= XAI_VIDEO_CREATE_PATH=/v1/videos/generations diff --git a/docs/source-analysis.html b/docs/source-analysis.html index b52ee15..3656ab8 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -690,9 +690,9 @@

后端核心

- + - + @@ -1324,6 +1324,18 @@ ProductRefStateItem {

影响:本地只配置 XAI_VIDEO_API_KEY 时,画布视频下拉只显示 Grok Imagine Video;同时配置有效 VIDEO_API_KEY 时才显示 Seedance。Kling / Veo 不会再因旧环境变量或旧缓存进入生成下拉。

+
+
+

2026-06-04 · Grok 视频创建阶段增加瞬时错误重试

+ API + Bugfix +
+
+

问题:生产排查刘凌的 Grok 视频失败时,后端状态显示模型已正确传为 grok-imagine-video,但 xAI 创建接口在返回 request_id 前出现 500 Internal Server ErrorConnection reset by peer,旧逻辑会第一次失败就把候选视频标为失败。

+

改动:api/main.py 给 Grok/xAI 创建阶段增加 VIDEO_CREATE_RETRY_ATTEMPTSVIDEO_CREATE_RETRY_BACKOFF_SECONDS,默认遇到连接重置、超时、429 或 5xx 自动退避重试 3 次;400/401/403/404 等明确参数或权限错误不重试。/health 暴露非敏感重试配置,错误提示把 5xx 归类为上游视频服务暂时异常。

+

影响:Grok 通道不再因一次上游瞬时 500/断连直接失败;仍然保留日志中的每次重试状态,方便后续区分网关波动、权限问题和内容审核失败。

+
+

2026-06-03 · 接入 xAI Grok Imagine Video

api/main.pyFastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_libraryasset_library 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 state.json / 资源库并写入索引;/canvas-projects 系列接口把画布项目按当前登录用户持久化,/canvas-workflows 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 POST /creative/jobs/image 把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;POST /prompt/polish 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品、平台或短视频广告话术。润色链路会先用 _strip_previous_polish_boilerplate 去掉旧模板尾巴,再用 _classify_prompt_intent 判断人物、无人、物体、场景、动物或未知主体,最后用 _repair_polished_prompt 修掉有人/无人矛盾、未写人却新增人物、未写 SKG 却出现 SKG 等冲突;_append_reference_image_person_guard 会在视频任务最终入队前给参考图请求追加条件提示,声明参考图里若有人物则按 AI 生成的虚拟角色处理;/health 返回 databaseimage_optionsimage_size_optionsvideo_optionsvideo_size_optionsvideo_duration_optionsvideo_max_duration_seconds/frames/{idx}/generatemodel 字段用于图片模型偏好,size 字段用于图片输出尺寸;/storyboard/video 继续使用 model 字段选择视频别名,并先校验画幅与时长能力边界,然后把 GeneratedVideo 写成 queued 占位并进入进程内视频队列。队列默认 VIDEO_QUEUE_MAX_CONCURRENT=2VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1,同一用户连续提交不会占满全局并发;排队任务会回写 queue_positionqueue_sizequeue_message。旧 AgentRun 一键出片状态机、TK 复刻接口和 POST /creative/copy 作为明确的 SKG 营销文案接口继续保留。
api/main.pyFastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_libraryasset_library 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 state.json / 资源库并写入索引;/canvas-projects 系列接口把画布项目按当前登录用户持久化,/canvas-workflows 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 POST /creative/jobs/image 把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;POST /prompt/polish 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品、平台或短视频广告话术。润色链路会先用 _strip_previous_polish_boilerplate 去掉旧模板尾巴,再用 _classify_prompt_intent 判断人物、无人、物体、场景、动物或未知主体,最后用 _repair_polished_prompt 修掉有人/无人矛盾、未写人却新增人物、未写 SKG 却出现 SKG 等冲突;_append_reference_image_person_guard 会在视频任务最终入队前给参考图请求追加条件提示,声明参考图里若有人物则按 AI 生成的虚拟角色处理;/health 返回 databaseimage_optionsimage_size_optionsvideo_optionsvideo_size_optionsvideo_duration_optionsvideo_max_duration_seconds 和视频创建重试配置;/frames/{idx}/generatemodel 字段用于图片模型偏好,size 字段用于图片输出尺寸;/storyboard/video 继续使用 model 字段选择视频别名,并先校验画幅与时长能力边界,然后把 GeneratedVideo 写成 queued 占位并进入进程内视频队列。Grok/xAI 创建阶段遇到连接重置、超时、429 或 5xx 会按 VIDEO_CREATE_RETRY_ATTEMPTSVIDEO_CREATE_RETRY_BACKOFF_SECONDS 自动退避重试,400/403 等明确错误不重试。队列默认 VIDEO_QUEUE_MAX_CONCURRENT=2VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1,同一用户连续提交不会占满全局并发;排队任务会回写 queue_positionqueue_sizequeue_message。旧 AgentRun 一键出片状态机、TK 复刻接口和 POST /creative/copy 作为明确的 SKG 营销文案接口继续保留。
api/db.pyPostgres 适配层:在 DATABASE_URL 存在且 psycopg 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD、我的工作流 CRUD,以及把 JobAgentRun、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled,生产 verify-prod-docker.sh 会要求 database.connected=true
video_model_options()视频模型能力出口:按当前视频网关过滤可真实路由的业务别名,Doubao / Ark 网关只暴露 doubao-seedance* 真实模型,Poe 网关才允许 Poe 的 Seedance / Kling / Veo 类模型;如果显式配置了 VIDEO_API_BASE_URLVIDEO_API_KEY 为空,默认视频通道会标记不可用,不再回退通用 LLM_API_KEY。新增 xai / grok-imagine-video 独立走 XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xaiXAI_VIDEO_API_KEY/v1/videos/generations/v1/videos/{id},创建返回 request_id、轮询完成返回 video.url;未配置 xAI key 时 /health 会标记不可用,前端不显示。
video_model_options()视频模型能力出口:按当前视频网关过滤可真实路由的业务别名,Doubao / Ark 网关只暴露 doubao-seedance* 真实模型,Poe 网关才允许 Poe 的 Seedance / Kling / Veo 类模型;如果显式配置了 VIDEO_API_BASE_URLVIDEO_API_KEY 为空,默认视频通道会标记不可用,不再回退通用 LLM_API_KEY。新增 xai / grok-imagine-video 独立走 XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xaiXAI_VIDEO_API_KEY/v1/videos/generations/v1/videos/{id},创建返回 request_id、轮询完成返回 video.url;未配置 xAI key 时 /health 会标记不可用,前端不显示。创建阶段的瞬时错误重试由 VIDEO_CREATE_RETRY_ATTEMPTS / VIDEO_CREATE_RETRY_BACKOFF_SECONDS 控制,并随 /health 暴露非敏感数值。
api/product_library/skg-products内置 SKG 白底产品图库:manifest.json 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,images/ 存 45 张参考图。
api/character_library/skg-characters内置相似主体形象库:从桌面 5 套策划形象导入,manifest.json 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 prompt_brief。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。
asset_library/全局素材库目录,和 jobs/ 平级,不写入任何 job state。四类目录为 subjectsproductsscenesvideos;每个素材自带 manifest.json 和图片/视频文件,index.json 只是启动扫描重建出来的缓存。库素材选用到 job 时必须复制文件到 jobs/<jobId>/assetsstoryboard-videos,禁止直接保存 library 引用。