free-short-video 7.0.1 → 7.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -4
- package/README.md +10 -23
- package/core/api/agnes_video.py +301 -36
- package/core/config.py +77 -7
- package/core/i18n_backend.py +13 -88
- package/core/pipelines/__init__.py +52 -1
- package/core/pipelines/anchor_video.py +2 -0
- package/core/pipelines/creative/steps_video.py +15 -0
- package/core/pipelines/multi_scene.py +17 -3
- package/core/pipelines/simple_video.py +17 -3
- package/models/task.py +11 -2
- package/package.json +1 -1
- package/static/assets/ar-BGwnHyw2.js +1 -0
- package/static/assets/bn-BZ1SVmZv.js +1 -0
- package/static/assets/de-CUKZPeV-.js +1 -0
- package/static/assets/es-DKx-teoF.js +1 -0
- package/static/assets/fa-DTgnsV-R.js +1 -0
- package/static/assets/fr-QDOIUFKu.js +1 -0
- package/static/assets/hi-CiRTbxUR.js +1 -0
- package/static/assets/id-DVJD5Y5W.js +1 -0
- package/static/assets/index-BC30W-Gs.js +45 -0
- package/static/assets/index-D9sLMHUd.css +1 -0
- package/static/assets/it-BsQ3SshU.js +1 -0
- package/static/assets/ja-D9O41FnB.js +1 -0
- package/static/assets/ko-BH0PhAjf.js +1 -0
- package/static/assets/ms-qcRbwWeG.js +1 -0
- package/static/assets/nl-BuXaX-G6.js +1 -0
- package/static/assets/pt-CC3c_p8Q.js +1 -0
- package/static/assets/ru-CYIQhw4j.js +1 -0
- package/static/assets/th-BHjOht4_.js +1 -0
- package/static/assets/tl-CLPCfL8g.js +1 -0
- package/static/assets/tr-_PXfKikt.js +1 -0
- package/static/assets/ur-CNslvoMb.js +1 -0
- package/static/assets/vi-BCoL1tPB.js +1 -0
- package/static/index.html +2 -2
- package/utils/network.py +52 -0
- package/web/helpers.py +25 -9
- package/web/routes/config_routes.py +11 -1
- package/web/routes/utility_routes.py +5 -1
- package/static/assets/ar-CT5Vb96u.js +0 -1
- package/static/assets/bn-BRFSrmpr.js +0 -1
- package/static/assets/de-BfbzdWS4.js +0 -1
- package/static/assets/es-DI_YGUav.js +0 -1
- package/static/assets/fa-CLlBTWBD.js +0 -1
- package/static/assets/fr-CrJe-R-B.js +0 -1
- package/static/assets/hi-Biw0K-wF.js +0 -1
- package/static/assets/id-CD_7Mjc4.js +0 -1
- package/static/assets/index-D3X3fptl.css +0 -1
- package/static/assets/index-Da6mRnWp.js +0 -45
- package/static/assets/it-c6SdEfC4.js +0 -1
- package/static/assets/ja-C1nVdqg3.js +0 -1
- package/static/assets/ko-DJJcPzZ7.js +0 -1
- package/static/assets/ms-BVvY5UaD.js +0 -1
- package/static/assets/nl-BFMVg2IF.js +0 -1
- package/static/assets/pt-Br9EP1M-.js +0 -1
- package/static/assets/ru-BXIDO_lx.js +0 -1
- package/static/assets/th-qi227Ckr.js +0 -1
- package/static/assets/tl-CI-FZE-c.js +0 -1
- package/static/assets/tr-4WitX2Ji.js +0 -1
- package/static/assets/ur-CYLssynd.js +0 -1
- package/static/assets/vi-oji0SPGU.js +0 -1
package/.env.example
CHANGED
|
@@ -36,11 +36,15 @@ HOST=0.0.0.0
|
|
|
36
36
|
PORT=8765
|
|
37
37
|
|
|
38
38
|
# 注:Agnes API 走哪个站点(国际站 / 国内站)**不通过环境变量配置**
|
|
39
|
-
# (AGNES_BASE_URL 已废弃,代码中不再读取)。请在 Web 设置页的 API Key
|
|
40
|
-
#
|
|
39
|
+
# (AGNES_BASE_URL 已废弃,代码中不再读取)。请在 Web 设置页的 API Key 面板选择全局默认域名。
|
|
40
|
+
# ⚠️ 重要:通过本文件 / 环境变量写入的 Key 属于 env 来源,**无法逐个绑定域名,也不参与
|
|
41
|
+
# Web 页的「自动探测域名」**——它们一律使用你在 Web 页设定的「全局默认域名」。因此:
|
|
42
|
+
# - 单站点 Key:把全局默认域名设成与该 Key 站点一致即可;
|
|
43
|
+
# - 混用不同站点的 Key:请改到 Web 配置页逐个添加(config 来源),再逐 Key 绑定域名
|
|
44
|
+
# 或点「自动探测域名」,才能各走各的站点(env Key 做不到这点)。
|
|
45
|
+
# 跨站使用 Key(如国际站 Key 打到国内站专属域名)会得到 HTTP 401 / 无效的令牌。
|
|
41
46
|
# 可选域名:com = apihub.agnes-ai.com(国际站)、cn = api.agnes-ai.cn(国内站,
|
|
42
|
-
# 需国内站专属 Key)、cn_bak = apihub.agnes-ai.cn
|
|
43
|
-
# 跨站使用 Key 会得到 HTTP 401 / 无效的令牌。
|
|
47
|
+
# 需国内站专属 Key)、cn_bak = apihub.agnes-ai.cn(备用,国内与国际 Key 均可用)。
|
|
44
48
|
|
|
45
49
|
|
|
46
50
|
# ── 4. 限速(可选)─────────────────────────────────────────────────
|
package/README.md
CHANGED
|
@@ -1,39 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
|
|
3
|
-
# What's New in v7.0.
|
|
3
|
+
# What's New in v7.0.3
|
|
4
4
|
|
|
5
5
|
## What's New
|
|
6
6
|
|
|
7
7
|
### Features & Improvements
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
been extended from high-impact messages to the complete set: ~120 remaining strings across
|
|
15
|
-
all six task types — progress updates for every pipeline step, task-creation and preview
|
|
16
|
-
validation errors, config/provider validation, voice-compatibility warnings and cleanup
|
|
17
|
-
tooling messages — are rendered in the task's UI language.
|
|
18
|
-
|
|
19
|
-
### Refactoring & Optimizations
|
|
20
|
-
|
|
21
|
-
- **Single bilingual message catalog for the backend.** All user-facing backend strings now
|
|
22
|
-
live in one translation catalog (~200 keys) with CI-enforced Chinese/English parity and a
|
|
23
|
-
documented contributor convention (domain-based key naming, keyword placeholders, per-request
|
|
24
|
-
vs per-task language sources), making the interface-language behavior a maintained invariant
|
|
25
|
-
rather than an ad-hoc practice.
|
|
9
|
+
* **Survive Agnes queue saturation.** When Agnes rejects submissions with `503 video_queue_full` (the free Video 2.5 Flash queue can stay full for 10+ minutes), the app now retries on a dedicated track — every 30–60s for up to 900s (`AGNES_VIDEO_QUEUE_RETRY_SECONDS`) — without consuming the normal retry budget, so you no longer get a premature "generation failed" while you were simply never queued. Nothing is consumed by a rejected submit. The progress panel shows it live: `Agnes 视频队列已满(HTTP 503 · video_queue_full),正在排队重试(第 N 次 / 已等 X 分钟)。建议错峰重试或稍后再试。`
|
|
10
|
+
* **Honest error messages.** Upstream failures now surface the real reason instead of a generic `HTTP 503: server error` or a raw dict dump: the submitted error's `code` and `message` are kept end-to-end (e.g. `ComfyUI internal error: inference not finished after 15 minutes (code=500)`), so "queue full" and "queued but stuck" are no longer indistinguishable. `error_logs/` stores readable text plus the upstream code.
|
|
11
|
+
* **Queue notices in your language (all 22 locales).** Queue-full progress and failure notices are now rendered by the frontend from structured i18n keys, so Japanese, Arabic, Russian, etc. users see their own language instead of a Chinese fallback. The raw upstream error (HTTP status, `video_queue_full`) is quoted untranslated, as it comes from the API.
|
|
12
|
+
* **Config panels start collapsed.** All four configuration cards (provider management, model selection, workspace, privacy) now start collapsed instead of expanded; your manual expand/collapse preference is still remembered per browser.
|
|
13
|
+
* **Know when a model is not adapted.** Video models that appear in the upstream model list but have no capability entry in your installed version are now marked with `⚠` plus an explicit notice, instead of being silently submitted with the old v2.0 pixel protocol. The app version is shown in the page footer and returned by `GET /api/models`.
|
|
26
14
|
|
|
27
15
|
### Bug Fixes
|
|
28
16
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
the task's UI language instead of hard-coded Chinese (issue #65 follow-up).
|
|
17
|
+
* **Switching the text model provider back to Agnes now works.** Previously the switch only changed the UI: the backend kept the previously selected third-party provider and kept calling its endpoint (an empty value was dropped by the form layer and treated as "no change"). The selection is now sent explicitly, and it persists when you click **Save**.
|
|
18
|
+
* **Frontend no longer breaks after an update.** The app entry page is now served with `Cache-Control: no-cache`, so browsers can no longer keep a stale entry page that references deleted build assets — the "task list (or the whole app) silently stopped working after pulling a new version" failure mode is gone. After upgrading, do one hard refresh (`Cmd/Ctrl+Shift+R`); afterwards updates apply on normal reload.
|
|
19
|
+
* Fixed the queue-full messaging to name Agnes, quote the raw upstream error, and include an actionable suggestion (retry off-peak), instead of the vague "upstream video queue is full".
|
|
33
20
|
|
|
34
21
|
---
|
|
35
22
|
|
|
36
|
-
No action is required after upgrading.
|
|
23
|
+
No action is required after upgrading. If your tasks sit at 0% for a long time, that is the Agnes free queue — the app now retries and tells you so; off-peak retries or switching to Video 2.0 (separate queue) remain the practical workarounds.
|
|
37
24
|
|
|
38
25
|
---
|
|
39
26
|
|
package/core/api/agnes_video.py
CHANGED
|
@@ -6,6 +6,8 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
import mimetypes
|
|
8
8
|
import os
|
|
9
|
+
import random
|
|
10
|
+
import subprocess
|
|
9
11
|
import time
|
|
10
12
|
from typing import List, Optional
|
|
11
13
|
|
|
@@ -15,10 +17,10 @@ from core.api.error_collector import collect_error, collect_error_from_exception
|
|
|
15
17
|
from core.api.key_manager import get_key_ring
|
|
16
18
|
from core.api.rate_limiter import get_rate_limiter, get_video_submit_limiter
|
|
17
19
|
from core.config import (
|
|
18
|
-
VIDEO_ASPECT_RATIOS,
|
|
19
20
|
get_agnes_api_root,
|
|
20
21
|
get_base_url_for_key,
|
|
21
22
|
is_v25_video_model,
|
|
23
|
+
width_height_to_aspect_ratio,
|
|
22
24
|
)
|
|
23
25
|
from utils.image_normalizer import normalize_reference_path
|
|
24
26
|
from utils.video import download_video
|
|
@@ -49,6 +51,105 @@ def _adaptive_poll_interval(interval: int, poll_count: int) -> int:
|
|
|
49
51
|
return min(interval, 20 + (poll_count // 5) * 5)
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
# ── v7.0 上游可靠性加固(docs/plans/v7.0/upstream_error_handling_plan.md)──
|
|
55
|
+
|
|
56
|
+
# 提交侧「队列类」瞬时错误的 body code(U1):走独立退避轨道,不消耗普通 5xx
|
|
57
|
+
# 的重试配额。实测 video_queue_full 可持续 12 分钟以上(连续 25 次被拒),
|
|
58
|
+
# 而普通 5xx 退避(5 次 × 30s 递增)约 5.5 分钟就会放弃。
|
|
59
|
+
_QUEUE_FULL_CODES = {"video_queue_full", "fail_to_fetch_task"}
|
|
60
|
+
|
|
61
|
+
# 队列满退避间隔(秒):固定基数 + 随机抖动(模块级常量,便于测试缩小)
|
|
62
|
+
_QUEUE_RETRY_BASE_DELAY = 30.0
|
|
63
|
+
_QUEUE_RETRY_JITTER = 30.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _upstream_error(body) -> tuple:
|
|
67
|
+
"""U2:从上游响应体统一提取 ``(code, message)``。
|
|
68
|
+
|
|
69
|
+
兼容两种输入:已解析的 dict(轮询结果)或 ``requests.Response``(提交响应)。
|
|
70
|
+
|
|
71
|
+
- 提交侧 5xx:body 形如 ``{"code": "video_queue_full", "message": "..."}``
|
|
72
|
+
- 轮询侧 ``status=failed``:``error`` 字段是对象 ``{"code", "message"}``
|
|
73
|
+
|
|
74
|
+
提取失败返回 ``("", "")``,由调用方回退到通用文案。
|
|
75
|
+
"""
|
|
76
|
+
data = body
|
|
77
|
+
if body is not None and hasattr(body, "json"):
|
|
78
|
+
try:
|
|
79
|
+
data = body.json()
|
|
80
|
+
except Exception:
|
|
81
|
+
return "", (getattr(body, "text", "") or "").strip()[:200]
|
|
82
|
+
if isinstance(data, dict):
|
|
83
|
+
code = str(data.get("code") or "")
|
|
84
|
+
message = str(data.get("message") or "")
|
|
85
|
+
err = data.get("error")
|
|
86
|
+
if isinstance(err, dict):
|
|
87
|
+
code = code or str(err.get("code") or "")
|
|
88
|
+
if not message:
|
|
89
|
+
message = str(err.get("message") or "")
|
|
90
|
+
return code, message
|
|
91
|
+
if data is None:
|
|
92
|
+
return "", ""
|
|
93
|
+
return "", str(data)[:200]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _needs_portrait_rotation_fix(perf_w, perf_h, cont_w, cont_h) -> bool:
|
|
97
|
+
"""U3:判定 2.5 系列「容器竖、像素横」的竖屏躺倒缺陷签名。
|
|
98
|
+
|
|
99
|
+
低代价代理判定(docs/dev/agnes_video_upstream_behavior.md §5.2):
|
|
100
|
+
推理内部尺寸(``perf_params``)为横屏而产物容器为竖屏 → 命中。
|
|
101
|
+
任一尺寸缺失 / 方向一致 → 不命中(保守:宁可漏判,不可误转)。
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
pw, ph, cw, ch = int(perf_w), int(perf_h), int(cont_w), int(cont_h)
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
return False
|
|
107
|
+
if pw <= 0 or ph <= 0 or cw <= 0 or ch <= 0:
|
|
108
|
+
return False
|
|
109
|
+
return pw > ph and ch > cw
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _probe_video_size(path: str) -> tuple:
|
|
113
|
+
"""ffprobe 读取视频容器宽高(U3 判定用;失败返回 ``(None, None)``)。"""
|
|
114
|
+
try:
|
|
115
|
+
from core.compositor.ffmpeg_tool import resolve_binary
|
|
116
|
+
ffprobe = resolve_binary("ffprobe")
|
|
117
|
+
if not ffprobe:
|
|
118
|
+
return None, None
|
|
119
|
+
result = subprocess.run(
|
|
120
|
+
[ffprobe, "-v", "error", "-select_streams", "v:0",
|
|
121
|
+
"-show_entries", "stream=width,height", "-of", "json", path],
|
|
122
|
+
capture_output=True, text=True, timeout=60,
|
|
123
|
+
)
|
|
124
|
+
streams = json.loads(result.stdout).get("streams") or []
|
|
125
|
+
if streams:
|
|
126
|
+
return streams[0].get("width"), streams[0].get("height")
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.debug(f"[UpstreamRotate] ffprobe failed: {e}")
|
|
129
|
+
return None, None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AgnesQueueFullError(RuntimeError):
|
|
133
|
+
"""U1(v7.0):队列类 503 在预算内始终没排进队——**未产生任务、未消耗配额**。
|
|
134
|
+
|
|
135
|
+
消息体只放技术事实(日志/traceback 用);用户可见文案交给 UI 层:
|
|
136
|
+
- 前端:按 ``i18n key = error.video.queue_full`` + 参数用 22 语言渲染;
|
|
137
|
+
- 后端兜底:``core.i18n_backend`` 的 zh/en(``utils.network.describe_queue_full_error``)。
|
|
138
|
+
|
|
139
|
+
注意:``is_remote_video_failure`` 对本类返回 False(提交阶段就被拒,
|
|
140
|
+
video_id 都不存在,自然不需要「服务端确认失败才丢弃 video_id」的判定)。
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, status: int, code: str, waited_s: int):
|
|
144
|
+
super().__init__(
|
|
145
|
+
f"Agnes video queue full (HTTP {status} · {code}) after {waited_s}s "
|
|
146
|
+
f"of retrying; no job was created"
|
|
147
|
+
)
|
|
148
|
+
self.queue_full_status = status
|
|
149
|
+
self.queue_full_code = code
|
|
150
|
+
self.queue_full_waited_s = waited_s
|
|
151
|
+
|
|
152
|
+
|
|
52
153
|
class VideoTaskCancelled(RuntimeError):
|
|
53
154
|
"""用户停止任务导致的取消(优化路线图 0.2)。
|
|
54
155
|
|
|
@@ -88,10 +189,16 @@ def _write_json_cache(path: str, data: dict) -> None:
|
|
|
88
189
|
|
|
89
190
|
|
|
90
191
|
class VideoOutput:
|
|
91
|
-
def __init__(self, fmt: str, ext: str, data: str
|
|
192
|
+
def __init__(self, fmt: str, ext: str, data: str,
|
|
193
|
+
perf_params: Optional[dict] = None,
|
|
194
|
+
fix_rotation: bool = False):
|
|
92
195
|
self.fmt = fmt
|
|
93
196
|
self.ext = ext
|
|
94
197
|
self.data = data
|
|
198
|
+
# U3:perf_params 为上游推理内部尺寸(非产物尺寸,勿用于产物校验);
|
|
199
|
+
# fix_rotation 开启时保存后探测「容器竖、像素横」签名并做方向校正
|
|
200
|
+
self.perf_params = perf_params or {}
|
|
201
|
+
self.fix_rotation = fix_rotation
|
|
95
202
|
|
|
96
203
|
async def save(self, path: str) -> None:
|
|
97
204
|
"""保存视频到 path(异步)。
|
|
@@ -108,6 +215,62 @@ class VideoOutput:
|
|
|
108
215
|
else:
|
|
109
216
|
with open(path, "wb") as f:
|
|
110
217
|
f.write(self.data if isinstance(self.data, bytes) else self.data.encode())
|
|
218
|
+
if self.fix_rotation:
|
|
219
|
+
self._maybe_fix_rotation(path)
|
|
220
|
+
|
|
221
|
+
def _maybe_fix_rotation(self, path: str) -> None:
|
|
222
|
+
"""U3:2.5 系列竖屏(9:16)上游躺倒缺陷的探测式校正(默认关闭)。
|
|
223
|
+
|
|
224
|
+
判定签名见 ``_needs_portrait_rotation_fix``(容器竖 + 推理内部横);
|
|
225
|
+
校正 = ffmpeg ``transpose=2``(逆时针 90°,实测可还原正确构图)。
|
|
226
|
+
任何一步失败保留原片并告警,不影响主流程。
|
|
227
|
+
开关:``AGNES_FIX_V25_PORTRAIT_ROTATION``(默认关闭,见计划 §三)。
|
|
228
|
+
"""
|
|
229
|
+
perf = self.perf_params or {}
|
|
230
|
+
perf_w, perf_h = perf.get("width"), perf.get("height")
|
|
231
|
+
if perf_w is None or perf_h is None:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
cont_w, cont_h = _probe_video_size(path)
|
|
235
|
+
if not _needs_portrait_rotation_fix(perf_w, perf_h, cont_w, cont_h):
|
|
236
|
+
return
|
|
237
|
+
from core.compositor.ffmpeg_tool import resolve_binary
|
|
238
|
+
ffmpeg = resolve_binary("ffmpeg")
|
|
239
|
+
if not ffmpeg:
|
|
240
|
+
logger.warning("[UpstreamRotate] ffmpeg unavailable, keep original video")
|
|
241
|
+
return
|
|
242
|
+
tmp_path = path + ".rotated.mp4"
|
|
243
|
+
cmd = [
|
|
244
|
+
ffmpeg, "-y", "-i", path,
|
|
245
|
+
"-vf", "transpose=2",
|
|
246
|
+
"-c:v", "libx264", "-crf", "18", "-preset", "fast",
|
|
247
|
+
"-c:a", "copy",
|
|
248
|
+
tmp_path,
|
|
249
|
+
]
|
|
250
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
|
251
|
+
if result.returncode != 0:
|
|
252
|
+
logger.warning(
|
|
253
|
+
f"[UpstreamRotate] ffmpeg failed (rc={result.returncode}): "
|
|
254
|
+
f"{result.stderr[:300]}"
|
|
255
|
+
)
|
|
256
|
+
_silent_remove(tmp_path)
|
|
257
|
+
return
|
|
258
|
+
os.replace(tmp_path, path)
|
|
259
|
+
logger.info(
|
|
260
|
+
f"[UpstreamRotate] Fixed portrait rotation: perf={perf_w}x{perf_h} "
|
|
261
|
+
f"container={cont_w}x{cont_h} -> transposed {os.path.basename(path)}"
|
|
262
|
+
)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.warning(f"[UpstreamRotate] rotation fix skipped: {e}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _silent_remove(path: str) -> None:
|
|
268
|
+
"""尽力删除临时文件(失败静默,供 U3 校正失败清理)。"""
|
|
269
|
+
try:
|
|
270
|
+
if os.path.exists(path):
|
|
271
|
+
os.remove(path)
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
111
274
|
|
|
112
275
|
|
|
113
276
|
class AgnesVideoAPI:
|
|
@@ -271,7 +434,7 @@ class AgnesVideoAPI:
|
|
|
271
434
|
return 961 # 480p tier
|
|
272
435
|
|
|
273
436
|
def _get_frame_config(self, duration: Optional[int] = None,
|
|
274
|
-
width: int =
|
|
437
|
+
width: int = 1280, height: int = 720) -> tuple:
|
|
275
438
|
d = duration or self.default_duration
|
|
276
439
|
max_nf = self._get_max_frames(width, height)
|
|
277
440
|
if d in DURATION_PRESETS:
|
|
@@ -351,6 +514,9 @@ class AgnesVideoAPI:
|
|
|
351
514
|
resp.raise_for_status()
|
|
352
515
|
result = resp.json()
|
|
353
516
|
status = result.get("status", "")
|
|
517
|
+
# U5:progress 与 internal_progress 实测互相矛盾(可 0 vs 100)、
|
|
518
|
+
# started_at 可为 null——仅作日志展示,任何逻辑不得依赖其数值
|
|
519
|
+
# (自适应间隔 _adaptive_poll_interval 也只按次数推进)。
|
|
354
520
|
progress = result.get("progress", 0)
|
|
355
521
|
poll_count += 1
|
|
356
522
|
consecutive_failures = 0 # reset on success
|
|
@@ -366,15 +532,23 @@ class AgnesVideoAPI:
|
|
|
366
532
|
return result
|
|
367
533
|
|
|
368
534
|
if status in ("failed", "FAILED"):
|
|
369
|
-
|
|
370
|
-
|
|
535
|
+
# U2:error 是对象 {code, message},统一提取而非字面量化
|
|
536
|
+
code, message = _upstream_error(result)
|
|
537
|
+
if not message:
|
|
538
|
+
message = str(result.get("error") or "unknown error")
|
|
539
|
+
error_msg = f"Video generation failed: {message}"
|
|
540
|
+
if code:
|
|
541
|
+
error_msg += f" (code={code})"
|
|
371
542
|
collect_error(
|
|
372
543
|
"video", "poll_task",
|
|
373
544
|
prompt=curl_cmd,
|
|
374
545
|
error_type="VideoFailed",
|
|
375
|
-
error_message=
|
|
546
|
+
error_message=message,
|
|
376
547
|
response_body=resp.text,
|
|
377
|
-
extra={
|
|
548
|
+
extra={
|
|
549
|
+
"video_id": video_id[:16], "status": status,
|
|
550
|
+
"upstream_code": code,
|
|
551
|
+
},
|
|
378
552
|
)
|
|
379
553
|
raise RuntimeError(error_msg)
|
|
380
554
|
except (requests.exceptions.RequestException, asyncio.TimeoutError) as e:
|
|
@@ -405,12 +579,18 @@ class AgnesVideoAPI:
|
|
|
405
579
|
# 优化路线图 1.3:自适应轮询间隔(20s 起步,每 5 次 +5s,上限 interval)
|
|
406
580
|
await asyncio.sleep(_adaptive_poll_interval(interval, poll_count))
|
|
407
581
|
|
|
408
|
-
async def _submit_with_retry(self, payload: dict, mode_desc: str
|
|
582
|
+
async def _submit_with_retry(self, payload: dict, mode_desc: str,
|
|
583
|
+
progress_callback=None) -> str:
|
|
409
584
|
frame_reductions_left = 2 # allow up to 2 frame-count reductions on 400
|
|
410
585
|
attempt = 0
|
|
411
586
|
rotations = 0
|
|
412
587
|
ring = get_key_ring()
|
|
413
588
|
max_rotations = len(ring) * self.max_retries
|
|
589
|
+
# U1:队列类 503(video_queue_full / fail_to_fetch_task)独立退避轨道
|
|
590
|
+
# —— 不计入普通 5xx 的 max_retries 配额,预算单独可配
|
|
591
|
+
queue_started = None # 首次命中时的时间戳(time.monotonic)
|
|
592
|
+
queue_deadline = None # queue_started + 预算秒数
|
|
593
|
+
queue_retries = 0
|
|
414
594
|
while attempt < self.max_retries:
|
|
415
595
|
if self.shutdown_event and self.shutdown_event.is_set():
|
|
416
596
|
raise VideoTaskCancelled("Video generation cancelled by user")
|
|
@@ -468,20 +648,100 @@ class AgnesVideoAPI:
|
|
|
468
648
|
continue
|
|
469
649
|
|
|
470
650
|
if resp.status_code >= 500:
|
|
651
|
+
# U2:解析响应体的 code/message(此前统一丢成 "server error")
|
|
652
|
+
code, message = _upstream_error(resp)
|
|
653
|
+
|
|
654
|
+
# U1:队列满走独立退避轨道(不计入普通 5xx 配额)
|
|
655
|
+
if code in _QUEUE_FULL_CODES:
|
|
656
|
+
if queue_deadline is None:
|
|
657
|
+
from core.config import get_settings
|
|
658
|
+
budget = get_settings().agnes_video_queue_retry_seconds
|
|
659
|
+
queue_started = time.monotonic()
|
|
660
|
+
queue_deadline = queue_started + budget
|
|
661
|
+
logger.warning(
|
|
662
|
+
f"[AgnesVideo] {mode_desc}: Agnes video queue full "
|
|
663
|
+
f"(HTTP {resp.status_code} · {code}), entering queue "
|
|
664
|
+
f"retry track (budget {budget}s)"
|
|
665
|
+
)
|
|
666
|
+
waited = time.monotonic() - queue_started
|
|
667
|
+
if time.monotonic() >= queue_deadline:
|
|
668
|
+
collect_error(
|
|
669
|
+
"video", "submit_video",
|
|
670
|
+
prompt=payload.get("prompt", ""),
|
|
671
|
+
error_type=f"QueueFull_{code}",
|
|
672
|
+
error_message=message or f"HTTP {resp.status_code}: {code}",
|
|
673
|
+
status_code=resp.status_code,
|
|
674
|
+
response_body=resp.text,
|
|
675
|
+
retry_count=queue_retries,
|
|
676
|
+
extra={
|
|
677
|
+
"mode": mode_desc, "upstream_code": code,
|
|
678
|
+
"waited_s": int(waited),
|
|
679
|
+
},
|
|
680
|
+
)
|
|
681
|
+
# 结构化异常:用户可见文案由 UI 层按 22 语言渲染,
|
|
682
|
+
# 此处只保留技术事实(含 HTTP 码 / body code / 等待时长)
|
|
683
|
+
raise AgnesQueueFullError(
|
|
684
|
+
status=resp.status_code, code=code, waited_s=int(waited),
|
|
685
|
+
)
|
|
686
|
+
delay = _QUEUE_RETRY_BASE_DELAY + random.uniform(0, _QUEUE_RETRY_JITTER)
|
|
687
|
+
queue_retries += 1
|
|
688
|
+
logger.warning(
|
|
689
|
+
f"[AgnesVideo] Agnes video queue full on {mode_desc} "
|
|
690
|
+
f"(HTTP {resp.status_code} · {code}), queue retry "
|
|
691
|
+
f"#{queue_retries} (waited {waited:.0f}s) in {delay:.0f}s..."
|
|
692
|
+
)
|
|
693
|
+
collect_error(
|
|
694
|
+
"video", "submit_video",
|
|
695
|
+
prompt=payload.get("prompt", ""),
|
|
696
|
+
error_type=f"QueueFull_{code}",
|
|
697
|
+
error_message=message or f"HTTP {resp.status_code}: {code}",
|
|
698
|
+
status_code=resp.status_code,
|
|
699
|
+
response_body=resp.text,
|
|
700
|
+
retry_count=queue_retries,
|
|
701
|
+
extra={
|
|
702
|
+
"mode": mode_desc, "upstream_code": code,
|
|
703
|
+
"waited_s": int(waited),
|
|
704
|
+
},
|
|
705
|
+
)
|
|
706
|
+
if progress_callback:
|
|
707
|
+
try:
|
|
708
|
+
# (stage, payload):payload 带原样报错(HTTP 码 + body code)
|
|
709
|
+
# 与等待信息,供前端拼出「Agnes 队列已满 + 原始报错 + 错峰建议」
|
|
710
|
+
progress_callback("queue_full", {
|
|
711
|
+
"attempt": queue_retries,
|
|
712
|
+
"waited_s": waited,
|
|
713
|
+
"status": resp.status_code,
|
|
714
|
+
"code": code,
|
|
715
|
+
"message": message,
|
|
716
|
+
})
|
|
717
|
+
except Exception:
|
|
718
|
+
logger.debug(
|
|
719
|
+
"[AgnesVideo] queue progress callback failed",
|
|
720
|
+
exc_info=True,
|
|
721
|
+
)
|
|
722
|
+
await asyncio.sleep(delay)
|
|
723
|
+
continue
|
|
724
|
+
|
|
725
|
+
# 普通 5xx:沿用 5 次退避配额
|
|
471
726
|
delay = self.retry_base_delay * (attempt + 1)
|
|
472
727
|
logger.warning(
|
|
473
|
-
f"[AgnesVideo] {resp.status_code} server error on {mode_desc}
|
|
728
|
+
f"[AgnesVideo] {resp.status_code} server error on {mode_desc}"
|
|
729
|
+
f"{f' (code={code})' if code else ''}, "
|
|
474
730
|
f"retry {attempt + 1}/{self.max_retries} in {delay:.0f}s..."
|
|
475
731
|
)
|
|
732
|
+
error_message = (
|
|
733
|
+
f"HTTP {resp.status_code}: {message}"
|
|
734
|
+
if message else f"HTTP {resp.status_code}: server error"
|
|
735
|
+
)
|
|
476
736
|
collect_error(
|
|
477
737
|
"video", "submit_video",
|
|
478
738
|
prompt=payload.get("prompt", ""),
|
|
479
739
|
error_type=f"HTTP{resp.status_code}",
|
|
480
|
-
error_message=
|
|
740
|
+
error_message=error_message,
|
|
481
741
|
status_code=resp.status_code,
|
|
482
742
|
response_body=resp.text,
|
|
483
743
|
retry_count=attempt + 1,
|
|
484
|
-
extra={"mode": mode_desc},
|
|
744
|
+
extra={"mode": mode_desc, "upstream_code": code},
|
|
485
745
|
)
|
|
486
746
|
await asyncio.sleep(delay)
|
|
487
747
|
attempt += 1
|
|
@@ -563,8 +823,8 @@ class AgnesVideoAPI:
|
|
|
563
823
|
prompt: str,
|
|
564
824
|
reference_image_paths: List[str] = [],
|
|
565
825
|
duration: Optional[int] = None,
|
|
566
|
-
width: int =
|
|
567
|
-
height: int =
|
|
826
|
+
width: int = 1280,
|
|
827
|
+
height: int = 720,
|
|
568
828
|
seed: Optional[int] = None,
|
|
569
829
|
negative_prompt: Optional[str] = None,
|
|
570
830
|
progress_callback=None,
|
|
@@ -578,6 +838,7 @@ class AgnesVideoAPI:
|
|
|
578
838
|
height=height,
|
|
579
839
|
seed=seed,
|
|
580
840
|
negative_prompt=negative_prompt,
|
|
841
|
+
progress_callback=progress_callback,
|
|
581
842
|
**kwargs,
|
|
582
843
|
)
|
|
583
844
|
return await self.wait_for_video(video_id, progress_callback)
|
|
@@ -587,10 +848,11 @@ class AgnesVideoAPI:
|
|
|
587
848
|
prompt: str,
|
|
588
849
|
reference_image_paths: List[str] = [],
|
|
589
850
|
duration: Optional[int] = None,
|
|
590
|
-
width: int =
|
|
591
|
-
height: int =
|
|
851
|
+
width: int = 1280,
|
|
852
|
+
height: int = 720,
|
|
592
853
|
seed: Optional[int] = None,
|
|
593
854
|
negative_prompt: Optional[str] = None,
|
|
855
|
+
progress_callback=None,
|
|
594
856
|
**kwargs,
|
|
595
857
|
) -> str:
|
|
596
858
|
# 2.5 系列模型(v6.2):新参数协议(mode/seconds/size/aspect_ratio)
|
|
@@ -602,6 +864,7 @@ class AgnesVideoAPI:
|
|
|
602
864
|
width=width,
|
|
603
865
|
height=height,
|
|
604
866
|
seed=seed,
|
|
867
|
+
progress_callback=progress_callback,
|
|
605
868
|
**kwargs,
|
|
606
869
|
)
|
|
607
870
|
num_frames, frame_rate = self._get_frame_config(duration, width, height)
|
|
@@ -643,38 +906,29 @@ class AgnesVideoAPI:
|
|
|
643
906
|
|
|
644
907
|
logger.info(f"[AgnesVideo] {mode_desc}: {prompt[:80]}...")
|
|
645
908
|
|
|
646
|
-
video_id = await self._submit_with_retry(payload, mode_desc)
|
|
909
|
+
video_id = await self._submit_with_retry(payload, mode_desc, progress_callback)
|
|
647
910
|
logger.info(f"[AgnesVideo] Video submitted: {video_id[:20]}...")
|
|
648
911
|
return video_id
|
|
649
912
|
|
|
650
913
|
@staticmethod
|
|
651
914
|
def _width_height_to_aspect_ratio(width: int, height: int) -> str:
|
|
652
|
-
"""
|
|
915
|
+
"""将像素宽高映射到 2.5 系列 aspect_ratio 枚举(委托 ``core.config``)。
|
|
653
916
|
|
|
654
|
-
|
|
917
|
+
映射规则已上提到 ``core.config.width_height_to_aspect_ratio``,供回归
|
|
918
|
+
校验脚本共用(保证「提交」与「校验」同一套规则);此方法保留为类内
|
|
919
|
+
调用入口,行为与上提前完全一致。
|
|
655
920
|
"""
|
|
656
|
-
|
|
657
|
-
return "16:9"
|
|
658
|
-
ratio = width / height
|
|
659
|
-
best = "16:9"
|
|
660
|
-
best_dist = float("inf")
|
|
661
|
-
for ar in VIDEO_ASPECT_RATIOS:
|
|
662
|
-
w, h = ar.split(":")
|
|
663
|
-
target = int(w) / int(h)
|
|
664
|
-
dist = abs(ratio - target)
|
|
665
|
-
if dist < best_dist:
|
|
666
|
-
best_dist = dist
|
|
667
|
-
best = ar
|
|
668
|
-
return best
|
|
921
|
+
return width_height_to_aspect_ratio(width, height)
|
|
669
922
|
|
|
670
923
|
async def _submit_video_v25(
|
|
671
924
|
self,
|
|
672
925
|
prompt: str,
|
|
673
926
|
reference_image_paths: List[str],
|
|
674
927
|
duration: Optional[int] = None,
|
|
675
|
-
width: int =
|
|
676
|
-
height: int =
|
|
928
|
+
width: int = 1280,
|
|
929
|
+
height: int = 720,
|
|
677
930
|
seed: Optional[int] = None,
|
|
931
|
+
progress_callback=None,
|
|
678
932
|
**kwargs,
|
|
679
933
|
) -> str:
|
|
680
934
|
"""2.5 / 2.5-flash 新协议提交:mode / seconds / size / aspect_ratio。
|
|
@@ -727,14 +981,15 @@ class AgnesVideoAPI:
|
|
|
727
981
|
mode_desc = "text-to-video"
|
|
728
982
|
|
|
729
983
|
logger.info(f"[AgnesVideo] {mode_desc}: {prompt[:80]}...")
|
|
730
|
-
video_id = await self._submit_with_retry(payload, mode_desc)
|
|
984
|
+
video_id = await self._submit_with_retry(payload, mode_desc, progress_callback)
|
|
731
985
|
logger.info(f"[AgnesVideo] Video submitted: {video_id[:20]}...")
|
|
732
986
|
return video_id
|
|
733
987
|
|
|
734
988
|
async def wait_for_video(self, video_id: str, progress_callback=None) -> VideoOutput:
|
|
735
989
|
# 1.2:轮询总超时可经 AGNES_VIDEO_POLL_TIMEOUT 配置(3.5 RuntimeSettings 收敛)
|
|
736
990
|
from core.config import get_settings
|
|
737
|
-
|
|
991
|
+
settings = get_settings()
|
|
992
|
+
poll_timeout = settings.agnes_video_poll_timeout
|
|
738
993
|
final = await self._poll_task(
|
|
739
994
|
video_id, progress_callback=progress_callback,
|
|
740
995
|
max_poll_duration=poll_timeout,
|
|
@@ -753,4 +1008,14 @@ class AgnesVideoAPI:
|
|
|
753
1008
|
raise RuntimeError(f"Agnes video: no URL in completed task: {final}")
|
|
754
1009
|
|
|
755
1010
|
logger.info(f"[AgnesVideo] Done: {video_url[:80]}...")
|
|
756
|
-
|
|
1011
|
+
# U3:携带推理内部尺寸 + 校正开关(默认关闭;仅 2.5 系列探测「容器竖、
|
|
1012
|
+
# 像素横」签名后 ffmpeg transpose=2 校正,详见计划 §三)
|
|
1013
|
+
fix_rotation = (
|
|
1014
|
+
settings.agnes_fix_v25_portrait_rotation
|
|
1015
|
+
and is_v25_video_model(self.model)
|
|
1016
|
+
)
|
|
1017
|
+
return VideoOutput(
|
|
1018
|
+
fmt="url", ext="mp4", data=video_url,
|
|
1019
|
+
perf_params=final.get("perf_params") or {},
|
|
1020
|
+
fix_rotation=fix_rotation,
|
|
1021
|
+
)
|