free-short-video 7.0.2 → 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/README.md +10 -7
- package/core/api/agnes_video.py +288 -13
- package/core/config.py +18 -5
- package/core/i18n_backend.py +13 -0
- 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 +9 -0
- 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/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 +1 -1
- package/utils/network.py +52 -0
- package/web/routes/config_routes.py +11 -1
- package/web/routes/utility_routes.py +5 -1
- package/static/assets/ar-CRbirOE3.js +0 -1
- package/static/assets/bn-DGXpxXqo.js +0 -1
- package/static/assets/de-CW_QJIGx.js +0 -1
- package/static/assets/es-DG3zPtKB.js +0 -1
- package/static/assets/fa-KkBv8EdH.js +0 -1
- package/static/assets/fr-zdohXdVm.js +0 -1
- package/static/assets/hi-g1ky89XQ.js +0 -1
- package/static/assets/id-C21bK9DP.js +0 -1
- package/static/assets/index-DNreiP_P.js +0 -45
- package/static/assets/it-C5Xbey_b.js +0 -1
- package/static/assets/ja-NciKY87B.js +0 -1
- package/static/assets/ko-DJ8XhzBo.js +0 -1
- package/static/assets/ms-DcwfkMJG.js +0 -1
- package/static/assets/nl-CnpYVEZB.js +0 -1
- package/static/assets/pt-DAP0auKP.js +0 -1
- package/static/assets/ru-CzKYIeBy.js +0 -1
- package/static/assets/th-Q_FQzUg5.js +0 -1
- package/static/assets/tl-DXi6nq4V.js +0 -1
- package/static/assets/tr-BOCBaAw1.js +0 -1
- package/static/assets/ur-K0JNi0pO.js +0 -1
- package/static/assets/vi-DR30o5T0.js +0 -1
package/README.md
CHANGED
|
@@ -1,23 +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
|
-
* **
|
|
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`.
|
|
12
14
|
|
|
13
15
|
### Bug Fixes
|
|
14
16
|
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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".
|
|
17
20
|
|
|
18
21
|
---
|
|
19
22
|
|
|
20
|
-
No action is required after upgrading. If
|
|
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.
|
|
21
24
|
|
|
22
25
|
---
|
|
23
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
|
|
|
@@ -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:
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -591,6 +852,7 @@ class AgnesVideoAPI:
|
|
|
591
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,7 +906,7 @@ 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
|
|
|
@@ -665,6 +928,7 @@ class AgnesVideoAPI:
|
|
|
665
928
|
width: int = 1280,
|
|
666
929
|
height: int = 720,
|
|
667
930
|
seed: Optional[int] = None,
|
|
931
|
+
progress_callback=None,
|
|
668
932
|
**kwargs,
|
|
669
933
|
) -> str:
|
|
670
934
|
"""2.5 / 2.5-flash 新协议提交:mode / seconds / size / aspect_ratio。
|
|
@@ -717,14 +981,15 @@ class AgnesVideoAPI:
|
|
|
717
981
|
mode_desc = "text-to-video"
|
|
718
982
|
|
|
719
983
|
logger.info(f"[AgnesVideo] {mode_desc}: {prompt[:80]}...")
|
|
720
|
-
video_id = await self._submit_with_retry(payload, mode_desc)
|
|
984
|
+
video_id = await self._submit_with_retry(payload, mode_desc, progress_callback)
|
|
721
985
|
logger.info(f"[AgnesVideo] Video submitted: {video_id[:20]}...")
|
|
722
986
|
return video_id
|
|
723
987
|
|
|
724
988
|
async def wait_for_video(self, video_id: str, progress_callback=None) -> VideoOutput:
|
|
725
989
|
# 1.2:轮询总超时可经 AGNES_VIDEO_POLL_TIMEOUT 配置(3.5 RuntimeSettings 收敛)
|
|
726
990
|
from core.config import get_settings
|
|
727
|
-
|
|
991
|
+
settings = get_settings()
|
|
992
|
+
poll_timeout = settings.agnes_video_poll_timeout
|
|
728
993
|
final = await self._poll_task(
|
|
729
994
|
video_id, progress_callback=progress_callback,
|
|
730
995
|
max_poll_duration=poll_timeout,
|
|
@@ -743,4 +1008,14 @@ class AgnesVideoAPI:
|
|
|
743
1008
|
raise RuntimeError(f"Agnes video: no URL in completed task: {final}")
|
|
744
1009
|
|
|
745
1010
|
logger.info(f"[AgnesVideo] Done: {video_url[:80]}...")
|
|
746
|
-
|
|
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
|
+
)
|
package/core/config.py
CHANGED
|
@@ -18,7 +18,7 @@ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
|
|
18
18
|
# ═══════════════════════════════════════════════════
|
|
19
19
|
# 应用版本号(v6.1 新增:发版时同步更新,见 docs/dev/release_process.md)
|
|
20
20
|
# ═══════════════════════════════════════════════════
|
|
21
|
-
APP_VERSION = "7.0.
|
|
21
|
+
APP_VERSION = "7.0.3"
|
|
22
22
|
|
|
23
23
|
# 未配置 API Key 时的统一报错文案(含免费获取与在线体验兜底,全站路由共用)
|
|
24
24
|
#
|
|
@@ -252,6 +252,13 @@ try:
|
|
|
252
252
|
agnes_subtitle_ass: bool = True # 2.1c 字幕 ASS 单链灰度开关
|
|
253
253
|
agnes_video_poll_timeout: int = 1800 # 1.2 视频轮询总超时
|
|
254
254
|
agnes_chat_timeout: int = 300 # stability_hardening P1:chat 单次读超时(默认与 chat_multimodal 对齐)
|
|
255
|
+
# v7.0 U1:队列类 503(video_queue_full)独立重试预算(秒)。
|
|
256
|
+
# 实测队列饱和可持续 12 分钟以上,普通 5xx 退避(~5.5 分钟)会在排进队
|
|
257
|
+
# 前放弃;此轨道不计入普通配额,默认 900s。
|
|
258
|
+
agnes_video_queue_retry_seconds: int = 900
|
|
259
|
+
# v7.0 U3:2.5 系列竖屏(9:16)上游躺倒缺陷的探测式校正开关。
|
|
260
|
+
# 默认关闭——待真实竖屏任务验证后再定默认值(计划 §三建议路径)。
|
|
261
|
+
agnes_fix_v25_portrait_rotation: bool = False
|
|
255
262
|
|
|
256
263
|
# ── CORS(PR #33 吸收 Phase 2:可配置跨源白名单)──
|
|
257
264
|
# 供独立本地伴侣工具(如 agnes-simple-ui)从浏览器跨源调用本服务 API。
|
|
@@ -294,6 +301,12 @@ except ImportError: # pragma: no cover - pydantic-settings 为必备依赖,
|
|
|
294
301
|
)
|
|
295
302
|
self.agnes_video_poll_timeout = int(os.environ.get("AGNES_VIDEO_POLL_TIMEOUT", "1800"))
|
|
296
303
|
self.agnes_chat_timeout = int(os.environ.get("AGNES_CHAT_TIMEOUT", "300"))
|
|
304
|
+
self.agnes_video_queue_retry_seconds = int(
|
|
305
|
+
os.environ.get("AGNES_VIDEO_QUEUE_RETRY_SECONDS", "900")
|
|
306
|
+
)
|
|
307
|
+
self.agnes_fix_v25_portrait_rotation = os.environ.get(
|
|
308
|
+
"AGNES_FIX_V25_PORTRAIT_ROTATION", "0"
|
|
309
|
+
).strip().lower() in ("1", "true", "on")
|
|
297
310
|
self.agnes_cors_origins = os.environ.get("AGNES_CORS_ORIGINS", "")
|
|
298
311
|
_cors_enabled = os.environ.get("AGNES_CORS_ENABLED", "").strip().lower()
|
|
299
312
|
if _cors_enabled in ("0", "false", "off"):
|
|
@@ -1169,8 +1182,8 @@ VIDEO_MODEL_CAPABILITIES = {
|
|
|
1169
1182
|
"max_ref_images": None, # 不限
|
|
1170
1183
|
"supports_ref_video": False,
|
|
1171
1184
|
"desc": {
|
|
1172
|
-
"zh": "免费旧版。支持任意像素分辨率、最长约 17
|
|
1173
|
-
"en": "Free legacy model. Arbitrary pixel resolution, up to ~17s, multi-image keyframes; supports negative prompt.",
|
|
1185
|
+
"zh": "免费旧版。支持任意像素分辨率、最长约 17 秒、多参考图关键帧;有负面提示词。输出分辨率由上游按标准档归一(标准档保真,非标准档会被吸附)。",
|
|
1186
|
+
"en": "Free legacy model. Arbitrary pixel resolution, up to ~17s, multi-image keyframes; supports negative prompt. Output resolution is normalized by upstream to standard tiers.",
|
|
1174
1187
|
},
|
|
1175
1188
|
},
|
|
1176
1189
|
"agnes-video-2.5": { # 付费模型
|
|
@@ -1218,8 +1231,8 @@ VIDEO_MODEL_CAPABILITIES = {
|
|
|
1218
1231
|
"max_ref_images": 5,
|
|
1219
1232
|
"supports_ref_video": False,
|
|
1220
1233
|
"desc": {
|
|
1221
|
-
"zh": "免费新版(限时免费)。固定 720P、4–12 秒、图片参考最多 5
|
|
1222
|
-
"en": "Free new model (limited time). Fixed 720P, 4–12s, up to 5 ref images; no negative prompt or ref video.",
|
|
1234
|
+
"zh": "免费新版(限时免费)。固定 720P、4–12 秒、图片参考最多 5 张;不支持负面提示词与参考视频。比例保真、绝对像素由上游按标准档归一(如 3:4 实际输出 834x1112)。",
|
|
1235
|
+
"en": "Free new model (limited time). Fixed 720P, 4–12s, up to 5 ref images; no negative prompt or ref video. Aspect ratio preserved; absolute pixels normalized by upstream (e.g. 3:4 renders at 834x1112).",
|
|
1223
1236
|
},
|
|
1224
1237
|
},
|
|
1225
1238
|
}
|
package/core/i18n_backend.py
CHANGED
|
@@ -380,6 +380,19 @@ CATALOG: Dict[str, Dict[str, str]] = {
|
|
|
380
380
|
"zh": "等待视频生成 {vid}...",
|
|
381
381
|
"en": "Waiting for video generation {vid}...",
|
|
382
382
|
},
|
|
383
|
+
# U1(v7.0):点名 Agnes + 原样报错(HTTP 码 / body code)+ 错峰重试建议。
|
|
384
|
+
# 参数:n 重试次序、waited 已等分钟、status HTTP 状态码、code 上游 body code
|
|
385
|
+
# 说明:这里是**后端兜底**(zh/en 双基线);前端命中同名 key 时用前端
|
|
386
|
+
# 22 语言文案渲染,见 models/task.py 的 current_message_key。
|
|
387
|
+
"progress.video.queue_full": {
|
|
388
|
+
"zh": "Agnes 视频队列已满(HTTP {status} · {code}),正在排队重试(第 {n} 次 / 已等 {waited} 分钟)。建议错峰重试或稍后再试。",
|
|
389
|
+
"en": "Agnes video queue is full (HTTP {status} · {code}), retrying (attempt {n} / waited {waited} min). Please retry later, ideally off-peak.",
|
|
390
|
+
},
|
|
391
|
+
# 队列重试预算耗尽(未产生任务、未消耗配额):同样点名 Agnes + 原样报错 + 可行动作
|
|
392
|
+
"error.video.queue_full": {
|
|
393
|
+
"zh": "Agnes 视频队列已满(HTTP {status} · {code}),重试 {waited} 分钟后仍未排进队,本次未生成任务(不消耗配额)。建议错峰重试或换用其他视频模型。",
|
|
394
|
+
"en": "Agnes video queue is full (HTTP {status} · {code}); after retrying for {waited} min the job still never entered the queue, so nothing was generated (no quota used). Please retry later, ideally off-peak, or switch to another video model.",
|
|
395
|
+
},
|
|
383
396
|
"progress.simple.completed": {
|
|
384
397
|
"zh": "视频生成完成",
|
|
385
398
|
"en": "Video generation complete",
|
|
@@ -217,6 +217,8 @@ class BasePipeline(ABC):
|
|
|
217
217
|
data: dict = None,
|
|
218
218
|
*,
|
|
219
219
|
preserve_step: bool = False,
|
|
220
|
+
message_key: str = None,
|
|
221
|
+
message_params: dict = None,
|
|
220
222
|
):
|
|
221
223
|
"""更新进度并持久化到 state(轮询模式)。
|
|
222
224
|
|
|
@@ -226,13 +228,18 @@ class BasePipeline(ABC):
|
|
|
226
228
|
Args:
|
|
227
229
|
step: 步骤 key,写入 ``current_step`` 供前端与诊断报告定位环节。
|
|
228
230
|
status: 步骤状态(running / completed / failed / awaiting_user)。
|
|
229
|
-
message:
|
|
231
|
+
message: 面向用户的进度文案(后端按 ui_language 译好的兜底句)。
|
|
230
232
|
progress: 进度比例 0.0 ~ 1.0。
|
|
231
233
|
data: 附带数据(仅回调透传,不落盘)。
|
|
232
234
|
preserve_step: True 时**不覆盖** ``current_step``,用于 ``error``
|
|
233
235
|
这类终态事件——它们表达的是「出了问题」而不是「在哪个环节」,
|
|
234
236
|
覆盖会把真实失败环节冲掉,使诊断报告归因失真(issue #56/#57
|
|
235
237
|
真实失败在视频下载,报告却显示 scene_config)。
|
|
238
|
+
message_key: 可选。结构化消息的 i18n key(如
|
|
239
|
+
``"progress.video.queue_full"``),与 ``message_params`` 一并
|
|
240
|
+
落盘,供前端用其 22 语言文案渲染;省略时清空这两个字段,
|
|
241
|
+
避免上一次的结构化消息残留(旧前端仍读 ``current_message``)。
|
|
242
|
+
message_params: ``message_key`` 的插值参数(如 ``{"n": 3}``)。
|
|
236
243
|
|
|
237
244
|
落盘策略(2.4 节流 + v6.4.8 终态强制):
|
|
238
245
|
- ``status == "running"`` 的高频进度更新按 0.5s 阈值合并落盘;
|
|
@@ -246,6 +253,8 @@ class BasePipeline(ABC):
|
|
|
246
253
|
self._state.current_status = status
|
|
247
254
|
self._state.current_progress = progress
|
|
248
255
|
self._state.current_message = message
|
|
256
|
+
self._state.current_message_key = message_key or ""
|
|
257
|
+
self._state.current_message_params = dict(message_params or {})
|
|
249
258
|
# 2.4:进度写盘节流——进度类字段高频更新时合并落盘(0.5s 阈值)。
|
|
250
259
|
# 关键状态(video_id / scenes / paragraphs 等)不经本路径,不受影响。
|
|
251
260
|
force_save = status != "running"
|
|
@@ -258,6 +267,8 @@ class BasePipeline(ABC):
|
|
|
258
267
|
"current_status": status,
|
|
259
268
|
"current_progress": progress,
|
|
260
269
|
"current_message": message,
|
|
270
|
+
"current_message_key": message_key or "",
|
|
271
|
+
"current_message_params": dict(message_params or {}),
|
|
261
272
|
}
|
|
262
273
|
if not preserve_step:
|
|
263
274
|
fields["current_step"] = step
|
|
@@ -377,6 +388,46 @@ class BasePipeline(ABC):
|
|
|
377
388
|
"""``translate(key, self._ui_lang(), **params)`` 的快捷方式。"""
|
|
378
389
|
return translate(key, self._ui_lang(), **params)
|
|
379
390
|
|
|
391
|
+
def _submit_progress_callback(self, step: str, progress: float):
|
|
392
|
+
"""U1(v7.0):提交侧进度回调,供 ``AgnesVideoAPI.submit_video`` 使用。
|
|
393
|
+
|
|
394
|
+
队列类 503(``video_queue_full``)命中独立重试轨道时,经此回调把
|
|
395
|
+
「Agnes 视频队列已满 + 原样报错 + 错峰建议」实时推给前端(任务状态轮询读取)。
|
|
396
|
+
回调为同步函数(API 层在重试循环内直接调用),内部调度异步 ``_emit``。
|
|
397
|
+
|
|
398
|
+
Args(回调载荷 dict):``attempt`` 重试次序、``waited_s`` 已等秒数、
|
|
399
|
+
``status`` HTTP 状态码、``code`` 上游 body code、``message`` 上游原始文案。
|
|
400
|
+
"""
|
|
401
|
+
def _cb(stage: str, data: dict) -> None:
|
|
402
|
+
if stage != "queue_full":
|
|
403
|
+
return
|
|
404
|
+
info = data or {}
|
|
405
|
+
waited = float(info.get("waited_s") or 0)
|
|
406
|
+
params = {
|
|
407
|
+
"n": int(info.get("attempt") or 1),
|
|
408
|
+
"waited": max(1, round(waited / 60)),
|
|
409
|
+
"status": info.get("status") or "",
|
|
410
|
+
"code": info.get("code") or "",
|
|
411
|
+
}
|
|
412
|
+
try:
|
|
413
|
+
asyncio.get_running_loop().create_task(self._emit(
|
|
414
|
+
step, "running",
|
|
415
|
+
# 兜底句仍按任务语言渲染(旧前端 / 前端未覆盖该 key 时使用)
|
|
416
|
+
self._t("progress.video.queue_full", **params),
|
|
417
|
+
progress,
|
|
418
|
+
# 结构化 key + 参数:前端命中后用自己的 22 语言文案渲染
|
|
419
|
+
message_key="progress.video.queue_full",
|
|
420
|
+
message_params=params,
|
|
421
|
+
))
|
|
422
|
+
except RuntimeError:
|
|
423
|
+
# 无运行中事件循环(理论不可达):降级日志
|
|
424
|
+
logger.warning(
|
|
425
|
+
f"[Pipeline] Agnes video queue full (HTTP {info.get('status')} · "
|
|
426
|
+
f"{info.get('code')}), queue retry #{info.get('attempt')} "
|
|
427
|
+
f"(waited {waited:.0f}s)"
|
|
428
|
+
)
|
|
429
|
+
return _cb
|
|
430
|
+
|
|
380
431
|
@abstractmethod
|
|
381
432
|
async def run(self, state: BaseTaskState) -> str:
|
|
382
433
|
"""执行流水线,返回最终视频路径。"""
|
|
@@ -239,6 +239,8 @@ class AnchorPipeline(MultiScenePipeline):
|
|
|
239
239
|
duration=5,
|
|
240
240
|
width=vw,
|
|
241
241
|
height=vh,
|
|
242
|
+
# U1(v7.0):队列满时实时向前端推「排队重试中」
|
|
243
|
+
progress_callback=self._submit_progress_callback("video_gen", 0.40),
|
|
242
244
|
)
|
|
243
245
|
self._save_task_json(clip_dir, {"video_id": video_id})
|
|
244
246
|
|