free-short-video 7.0.2 → 7.0.4
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 +9 -7
- package/core/api/agnes_video.py +306 -13
- package/core/api/error_collector.py +63 -0
- 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-KzCkI4Os.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/task_routes.py +6 -0
- package/web/routes/utility_routes.py +5 -1
- package/web/routes/video_routes.py +7 -0
- 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,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
|
|
3
|
-
# What's New in v7.0.
|
|
3
|
+
# What's New in v7.0.4
|
|
4
4
|
|
|
5
5
|
## What's New
|
|
6
6
|
|
|
7
7
|
### Features & Improvements
|
|
8
8
|
|
|
9
|
-
* **
|
|
10
|
-
* **
|
|
11
|
-
|
|
9
|
+
* **Upstream API error trends.** Errors that carry an HTTP status code are now aggregated per task — by status code, model type (chat / image / video) and API method — and returned by the task detail API. The frontend reports them anonymously as `api_error` events, and task-failure / creation-failure events now carry the extracted status code as well, so recurring upstream failures (for example `503 video_queue_full`) can be tracked as a trend over time instead of being seen as unrelated single errors. The aggregation lives in memory for the lifetime of the task and is cleared with it; `error_logs/` remains the offline source of truth.
|
|
10
|
+
* **Honest "not ready yet" state while polling.** A transient `404` while waiting for a submitted video is now an expected intermediate state rather than an error. It no longer consumes the consecutive-failure budget and no longer gets flushed into `error_logs`, so the failure panel and the logs stop reporting failures for tasks that were in fact still queued upstream.
|
|
11
|
+
|
|
12
|
+
### Refactoring & Optimizations
|
|
13
|
+
|
|
14
|
+
* Regression coverage for the upstream error-handling path: consecutive `404`s followed by a successful completion, and a permanent `404` that correctly falls back to the overall polling timeout.
|
|
12
15
|
|
|
13
16
|
### Bug Fixes
|
|
14
17
|
|
|
15
|
-
* Fixed
|
|
16
|
-
* Corrected the Docker/compose image reference to the real flat GHCR namespace, so documented pull commands actually resolve.
|
|
18
|
+
* Fixed video tasks failing with a spurious error when the upstream gateway had not made the task record visible yet — a state observed for 420s and even 1100s+ under peak load. The overall polling timeout stays as the final backstop, and it keeps the `video_id` so the task can be resumed rather than reported as a server-confirmed failure.
|
|
17
19
|
|
|
18
20
|
---
|
|
19
21
|
|
|
20
|
-
No action is required after upgrading. If
|
|
22
|
+
No action is required after upgrading. If a task still fails, the failure panel now shows the real upstream reason with its HTTP status code, and repeated upstream errors of the same kind become visible as a trend.
|
|
21
23
|
|
|
22
24
|
---
|
|
23
25
|
|
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:
|
|
@@ -298,6 +461,12 @@ class AgnesVideoAPI:
|
|
|
298
461
|
last_status = ""
|
|
299
462
|
poll_count = 0
|
|
300
463
|
consecutive_failures = 0
|
|
464
|
+
# U9(v7.0.4):提交返回的是网关路由键,任务记录入库可见有延迟——
|
|
465
|
+
# 轮询侧 404 "task not found" 是「任务还没准备好」的中间态而非错误
|
|
466
|
+
# (实测上游高峰期可见性延迟可从历史 ~20s 劣化到 10min+),单独计数、
|
|
467
|
+
# 只做周期性 info 日志,不进 error_logs、不消耗连续失败配额;
|
|
468
|
+
# 最终兜底是循环顶部的 max_poll_duration 整体超时。
|
|
469
|
+
not_found_count = 0
|
|
301
470
|
start_time = asyncio.get_event_loop().time()
|
|
302
471
|
# 2.5 系列查询需带 model_name(text 模式可省略,但带上更通用)
|
|
303
472
|
model_param = f"&model_name={self.model}" if is_v25_video_model(self.model) else ""
|
|
@@ -348,9 +517,24 @@ class AgnesVideoAPI:
|
|
|
348
517
|
logger.warning("[KeyRotation] HTTP 429 on poll, 换 Key 立即重试")
|
|
349
518
|
continue
|
|
350
519
|
break
|
|
520
|
+
# U9:404(task not found)= 记录尚未在网关任务库可见,中间态
|
|
521
|
+
if resp.status_code == 404:
|
|
522
|
+
not_found_count += 1
|
|
523
|
+
poll_count += 1 # 计入自适应间隔与周期日志的推进
|
|
524
|
+
if not_found_count == 1 or not_found_count % 10 == 0:
|
|
525
|
+
logger.info(
|
|
526
|
+
f"[AgnesVideo] Video {video_id[:16]}... not yet visible "
|
|
527
|
+
f"(404 task not found), poll #{not_found_count}, "
|
|
528
|
+
f"elapsed {elapsed:.0f}s"
|
|
529
|
+
)
|
|
530
|
+
await asyncio.sleep(_adaptive_poll_interval(interval, poll_count))
|
|
531
|
+
continue
|
|
351
532
|
resp.raise_for_status()
|
|
352
533
|
result = resp.json()
|
|
353
534
|
status = result.get("status", "")
|
|
535
|
+
# U5:progress 与 internal_progress 实测互相矛盾(可 0 vs 100)、
|
|
536
|
+
# started_at 可为 null——仅作日志展示,任何逻辑不得依赖其数值
|
|
537
|
+
# (自适应间隔 _adaptive_poll_interval 也只按次数推进)。
|
|
354
538
|
progress = result.get("progress", 0)
|
|
355
539
|
poll_count += 1
|
|
356
540
|
consecutive_failures = 0 # reset on success
|
|
@@ -366,15 +550,23 @@ class AgnesVideoAPI:
|
|
|
366
550
|
return result
|
|
367
551
|
|
|
368
552
|
if status in ("failed", "FAILED"):
|
|
369
|
-
|
|
370
|
-
|
|
553
|
+
# U2:error 是对象 {code, message},统一提取而非字面量化
|
|
554
|
+
code, message = _upstream_error(result)
|
|
555
|
+
if not message:
|
|
556
|
+
message = str(result.get("error") or "unknown error")
|
|
557
|
+
error_msg = f"Video generation failed: {message}"
|
|
558
|
+
if code:
|
|
559
|
+
error_msg += f" (code={code})"
|
|
371
560
|
collect_error(
|
|
372
561
|
"video", "poll_task",
|
|
373
562
|
prompt=curl_cmd,
|
|
374
563
|
error_type="VideoFailed",
|
|
375
|
-
error_message=
|
|
564
|
+
error_message=message,
|
|
376
565
|
response_body=resp.text,
|
|
377
|
-
extra={
|
|
566
|
+
extra={
|
|
567
|
+
"video_id": video_id[:16], "status": status,
|
|
568
|
+
"upstream_code": code,
|
|
569
|
+
},
|
|
378
570
|
)
|
|
379
571
|
raise RuntimeError(error_msg)
|
|
380
572
|
except (requests.exceptions.RequestException, asyncio.TimeoutError) as e:
|
|
@@ -405,12 +597,18 @@ class AgnesVideoAPI:
|
|
|
405
597
|
# 优化路线图 1.3:自适应轮询间隔(20s 起步,每 5 次 +5s,上限 interval)
|
|
406
598
|
await asyncio.sleep(_adaptive_poll_interval(interval, poll_count))
|
|
407
599
|
|
|
408
|
-
async def _submit_with_retry(self, payload: dict, mode_desc: str
|
|
600
|
+
async def _submit_with_retry(self, payload: dict, mode_desc: str,
|
|
601
|
+
progress_callback=None) -> str:
|
|
409
602
|
frame_reductions_left = 2 # allow up to 2 frame-count reductions on 400
|
|
410
603
|
attempt = 0
|
|
411
604
|
rotations = 0
|
|
412
605
|
ring = get_key_ring()
|
|
413
606
|
max_rotations = len(ring) * self.max_retries
|
|
607
|
+
# U1:队列类 503(video_queue_full / fail_to_fetch_task)独立退避轨道
|
|
608
|
+
# —— 不计入普通 5xx 的 max_retries 配额,预算单独可配
|
|
609
|
+
queue_started = None # 首次命中时的时间戳(time.monotonic)
|
|
610
|
+
queue_deadline = None # queue_started + 预算秒数
|
|
611
|
+
queue_retries = 0
|
|
414
612
|
while attempt < self.max_retries:
|
|
415
613
|
if self.shutdown_event and self.shutdown_event.is_set():
|
|
416
614
|
raise VideoTaskCancelled("Video generation cancelled by user")
|
|
@@ -468,20 +666,100 @@ class AgnesVideoAPI:
|
|
|
468
666
|
continue
|
|
469
667
|
|
|
470
668
|
if resp.status_code >= 500:
|
|
669
|
+
# U2:解析响应体的 code/message(此前统一丢成 "server error")
|
|
670
|
+
code, message = _upstream_error(resp)
|
|
671
|
+
|
|
672
|
+
# U1:队列满走独立退避轨道(不计入普通 5xx 配额)
|
|
673
|
+
if code in _QUEUE_FULL_CODES:
|
|
674
|
+
if queue_deadline is None:
|
|
675
|
+
from core.config import get_settings
|
|
676
|
+
budget = get_settings().agnes_video_queue_retry_seconds
|
|
677
|
+
queue_started = time.monotonic()
|
|
678
|
+
queue_deadline = queue_started + budget
|
|
679
|
+
logger.warning(
|
|
680
|
+
f"[AgnesVideo] {mode_desc}: Agnes video queue full "
|
|
681
|
+
f"(HTTP {resp.status_code} · {code}), entering queue "
|
|
682
|
+
f"retry track (budget {budget}s)"
|
|
683
|
+
)
|
|
684
|
+
waited = time.monotonic() - queue_started
|
|
685
|
+
if time.monotonic() >= queue_deadline:
|
|
686
|
+
collect_error(
|
|
687
|
+
"video", "submit_video",
|
|
688
|
+
prompt=payload.get("prompt", ""),
|
|
689
|
+
error_type=f"QueueFull_{code}",
|
|
690
|
+
error_message=message or f"HTTP {resp.status_code}: {code}",
|
|
691
|
+
status_code=resp.status_code,
|
|
692
|
+
response_body=resp.text,
|
|
693
|
+
retry_count=queue_retries,
|
|
694
|
+
extra={
|
|
695
|
+
"mode": mode_desc, "upstream_code": code,
|
|
696
|
+
"waited_s": int(waited),
|
|
697
|
+
},
|
|
698
|
+
)
|
|
699
|
+
# 结构化异常:用户可见文案由 UI 层按 22 语言渲染,
|
|
700
|
+
# 此处只保留技术事实(含 HTTP 码 / body code / 等待时长)
|
|
701
|
+
raise AgnesQueueFullError(
|
|
702
|
+
status=resp.status_code, code=code, waited_s=int(waited),
|
|
703
|
+
)
|
|
704
|
+
delay = _QUEUE_RETRY_BASE_DELAY + random.uniform(0, _QUEUE_RETRY_JITTER)
|
|
705
|
+
queue_retries += 1
|
|
706
|
+
logger.warning(
|
|
707
|
+
f"[AgnesVideo] Agnes video queue full on {mode_desc} "
|
|
708
|
+
f"(HTTP {resp.status_code} · {code}), queue retry "
|
|
709
|
+
f"#{queue_retries} (waited {waited:.0f}s) in {delay:.0f}s..."
|
|
710
|
+
)
|
|
711
|
+
collect_error(
|
|
712
|
+
"video", "submit_video",
|
|
713
|
+
prompt=payload.get("prompt", ""),
|
|
714
|
+
error_type=f"QueueFull_{code}",
|
|
715
|
+
error_message=message or f"HTTP {resp.status_code}: {code}",
|
|
716
|
+
status_code=resp.status_code,
|
|
717
|
+
response_body=resp.text,
|
|
718
|
+
retry_count=queue_retries,
|
|
719
|
+
extra={
|
|
720
|
+
"mode": mode_desc, "upstream_code": code,
|
|
721
|
+
"waited_s": int(waited),
|
|
722
|
+
},
|
|
723
|
+
)
|
|
724
|
+
if progress_callback:
|
|
725
|
+
try:
|
|
726
|
+
# (stage, payload):payload 带原样报错(HTTP 码 + body code)
|
|
727
|
+
# 与等待信息,供前端拼出「Agnes 队列已满 + 原始报错 + 错峰建议」
|
|
728
|
+
progress_callback("queue_full", {
|
|
729
|
+
"attempt": queue_retries,
|
|
730
|
+
"waited_s": waited,
|
|
731
|
+
"status": resp.status_code,
|
|
732
|
+
"code": code,
|
|
733
|
+
"message": message,
|
|
734
|
+
})
|
|
735
|
+
except Exception:
|
|
736
|
+
logger.debug(
|
|
737
|
+
"[AgnesVideo] queue progress callback failed",
|
|
738
|
+
exc_info=True,
|
|
739
|
+
)
|
|
740
|
+
await asyncio.sleep(delay)
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
# 普通 5xx:沿用 5 次退避配额
|
|
471
744
|
delay = self.retry_base_delay * (attempt + 1)
|
|
472
745
|
logger.warning(
|
|
473
|
-
f"[AgnesVideo] {resp.status_code} server error on {mode_desc}
|
|
746
|
+
f"[AgnesVideo] {resp.status_code} server error on {mode_desc}"
|
|
747
|
+
f"{f' (code={code})' if code else ''}, "
|
|
474
748
|
f"retry {attempt + 1}/{self.max_retries} in {delay:.0f}s..."
|
|
475
749
|
)
|
|
750
|
+
error_message = (
|
|
751
|
+
f"HTTP {resp.status_code}: {message}"
|
|
752
|
+
if message else f"HTTP {resp.status_code}: server error"
|
|
753
|
+
)
|
|
476
754
|
collect_error(
|
|
477
755
|
"video", "submit_video",
|
|
478
756
|
prompt=payload.get("prompt", ""),
|
|
479
757
|
error_type=f"HTTP{resp.status_code}",
|
|
480
|
-
error_message=
|
|
758
|
+
error_message=error_message,
|
|
481
759
|
status_code=resp.status_code,
|
|
482
760
|
response_body=resp.text,
|
|
483
761
|
retry_count=attempt + 1,
|
|
484
|
-
extra={"mode": mode_desc},
|
|
762
|
+
extra={"mode": mode_desc, "upstream_code": code},
|
|
485
763
|
)
|
|
486
764
|
await asyncio.sleep(delay)
|
|
487
765
|
attempt += 1
|
|
@@ -578,6 +856,7 @@ class AgnesVideoAPI:
|
|
|
578
856
|
height=height,
|
|
579
857
|
seed=seed,
|
|
580
858
|
negative_prompt=negative_prompt,
|
|
859
|
+
progress_callback=progress_callback,
|
|
581
860
|
**kwargs,
|
|
582
861
|
)
|
|
583
862
|
return await self.wait_for_video(video_id, progress_callback)
|
|
@@ -591,6 +870,7 @@ class AgnesVideoAPI:
|
|
|
591
870
|
height: int = 720,
|
|
592
871
|
seed: Optional[int] = None,
|
|
593
872
|
negative_prompt: Optional[str] = None,
|
|
873
|
+
progress_callback=None,
|
|
594
874
|
**kwargs,
|
|
595
875
|
) -> str:
|
|
596
876
|
# 2.5 系列模型(v6.2):新参数协议(mode/seconds/size/aspect_ratio)
|
|
@@ -602,6 +882,7 @@ class AgnesVideoAPI:
|
|
|
602
882
|
width=width,
|
|
603
883
|
height=height,
|
|
604
884
|
seed=seed,
|
|
885
|
+
progress_callback=progress_callback,
|
|
605
886
|
**kwargs,
|
|
606
887
|
)
|
|
607
888
|
num_frames, frame_rate = self._get_frame_config(duration, width, height)
|
|
@@ -643,7 +924,7 @@ class AgnesVideoAPI:
|
|
|
643
924
|
|
|
644
925
|
logger.info(f"[AgnesVideo] {mode_desc}: {prompt[:80]}...")
|
|
645
926
|
|
|
646
|
-
video_id = await self._submit_with_retry(payload, mode_desc)
|
|
927
|
+
video_id = await self._submit_with_retry(payload, mode_desc, progress_callback)
|
|
647
928
|
logger.info(f"[AgnesVideo] Video submitted: {video_id[:20]}...")
|
|
648
929
|
return video_id
|
|
649
930
|
|
|
@@ -665,6 +946,7 @@ class AgnesVideoAPI:
|
|
|
665
946
|
width: int = 1280,
|
|
666
947
|
height: int = 720,
|
|
667
948
|
seed: Optional[int] = None,
|
|
949
|
+
progress_callback=None,
|
|
668
950
|
**kwargs,
|
|
669
951
|
) -> str:
|
|
670
952
|
"""2.5 / 2.5-flash 新协议提交:mode / seconds / size / aspect_ratio。
|
|
@@ -717,14 +999,15 @@ class AgnesVideoAPI:
|
|
|
717
999
|
mode_desc = "text-to-video"
|
|
718
1000
|
|
|
719
1001
|
logger.info(f"[AgnesVideo] {mode_desc}: {prompt[:80]}...")
|
|
720
|
-
video_id = await self._submit_with_retry(payload, mode_desc)
|
|
1002
|
+
video_id = await self._submit_with_retry(payload, mode_desc, progress_callback)
|
|
721
1003
|
logger.info(f"[AgnesVideo] Video submitted: {video_id[:20]}...")
|
|
722
1004
|
return video_id
|
|
723
1005
|
|
|
724
1006
|
async def wait_for_video(self, video_id: str, progress_callback=None) -> VideoOutput:
|
|
725
1007
|
# 1.2:轮询总超时可经 AGNES_VIDEO_POLL_TIMEOUT 配置(3.5 RuntimeSettings 收敛)
|
|
726
1008
|
from core.config import get_settings
|
|
727
|
-
|
|
1009
|
+
settings = get_settings()
|
|
1010
|
+
poll_timeout = settings.agnes_video_poll_timeout
|
|
728
1011
|
final = await self._poll_task(
|
|
729
1012
|
video_id, progress_callback=progress_callback,
|
|
730
1013
|
max_poll_duration=poll_timeout,
|
|
@@ -743,4 +1026,14 @@ class AgnesVideoAPI:
|
|
|
743
1026
|
raise RuntimeError(f"Agnes video: no URL in completed task: {final}")
|
|
744
1027
|
|
|
745
1028
|
logger.info(f"[AgnesVideo] Done: {video_url[:80]}...")
|
|
746
|
-
|
|
1029
|
+
# U3:携带推理内部尺寸 + 校正开关(默认关闭;仅 2.5 系列探测「容器竖、
|
|
1030
|
+
# 像素横」签名后 ffmpeg transpose=2 校正,详见计划 §三)
|
|
1031
|
+
fix_rotation = (
|
|
1032
|
+
settings.agnes_fix_v25_portrait_rotation
|
|
1033
|
+
and is_v25_video_model(self.model)
|
|
1034
|
+
)
|
|
1035
|
+
return VideoOutput(
|
|
1036
|
+
fmt="url", ext="mp4", data=video_url,
|
|
1037
|
+
perf_params=final.get("perf_params") or {},
|
|
1038
|
+
fix_rotation=fix_rotation,
|
|
1039
|
+
)
|
|
@@ -33,6 +33,63 @@ _WORKSPACE_ROOT: Optional[Path] = None
|
|
|
33
33
|
# 用 contextvar 而非逐层传参,将改动面收敛到 API 模块内部(PRD FR9)。
|
|
34
34
|
_TASK_ID_CTX: ContextVar[str] = ContextVar("error_collector_task_id", default="")
|
|
35
35
|
|
|
36
|
+
# ── GA 埋点:上游接口报错聚合(内存态,按 task_id 分组)──
|
|
37
|
+
# 结构:{task_id: {"{status_code}|{model_type}|{api_method}": {
|
|
38
|
+
# "status_code": int, "model_type": str, "api_method": str,
|
|
39
|
+
# "count": int, "first_ts": iso, "last_ts": iso}}}
|
|
40
|
+
#
|
|
41
|
+
# 设计说明:故意**不写入 task_state.json**——流水线持有自己的 TaskManager
|
|
42
|
+
# 缓存实例,每次 update_state 都会拿缓存快照整份覆盖落盘,error_collector
|
|
43
|
+
# 若单独写盘会被下一次流水线落盘冲掉。改为内存聚合 +
|
|
44
|
+
# GET /api/tasks/{id}(web/routes/task_routes.py)读取时合并下发,
|
|
45
|
+
# 前端轮询到新增量即上报 GA `api_error` 事件(503 趋势分析用)。
|
|
46
|
+
# 进程重启后内存聚合清零属预期:前端埋点本身只覆盖"页面开着"的窗口,
|
|
47
|
+
# 完整离线趋势仍以 error_logs/ 落盘文件为准。
|
|
48
|
+
_TASK_ERROR_AGG: dict = {}
|
|
49
|
+
_MAX_AGG_ENTRIES_PER_TASK = 32 # 按维度聚合后条目数上限,防异常状态码刷爆
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _bump_upstream_error(task_id: str, status_code: int, model_type: str, api_method: str) -> None:
|
|
53
|
+
"""累加一次带 HTTP 状态码的上游报错(内存聚合,失败静默不影响主流程)。"""
|
|
54
|
+
try:
|
|
55
|
+
now = datetime.now().isoformat(timespec="seconds")
|
|
56
|
+
per_task = _TASK_ERROR_AGG.setdefault(task_id, {})
|
|
57
|
+
key = f"{status_code}|{model_type}|{api_method}"
|
|
58
|
+
entry = per_task.get(key)
|
|
59
|
+
if entry is None:
|
|
60
|
+
if len(per_task) >= _MAX_AGG_ENTRIES_PER_TASK:
|
|
61
|
+
logger.warning(
|
|
62
|
+
f"[ErrorCollector] Upstream error agg cap "
|
|
63
|
+
f"({_MAX_AGG_ENTRIES_PER_TASK}) reached for task {task_id}, dropping"
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
per_task[key] = {
|
|
67
|
+
"status_code": status_code,
|
|
68
|
+
"model_type": model_type,
|
|
69
|
+
"api_method": api_method,
|
|
70
|
+
"count": 1,
|
|
71
|
+
"first_ts": now,
|
|
72
|
+
"last_ts": now,
|
|
73
|
+
}
|
|
74
|
+
else:
|
|
75
|
+
entry["count"] += 1
|
|
76
|
+
entry["last_ts"] = now
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.debug(f"[ErrorCollector] Failed to bump upstream error agg: {e}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_task_upstream_errors(task_id: str) -> list:
|
|
82
|
+
"""返回某任务聚合后的上游报错列表(按首次发生时间排序,供任务详情端点合并下发)。"""
|
|
83
|
+
per_task = _TASK_ERROR_AGG.get(task_id or "")
|
|
84
|
+
if not per_task:
|
|
85
|
+
return []
|
|
86
|
+
return sorted(per_task.values(), key=lambda e: e.get("first_ts", ""))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def clear_task_upstream_errors(task_id: str) -> None:
|
|
90
|
+
"""任务删除时清理对应聚合(防内存缓慢增长)。"""
|
|
91
|
+
_TASK_ERROR_AGG.pop(task_id or "", None)
|
|
92
|
+
|
|
36
93
|
|
|
37
94
|
def set_workspace_root(path: str) -> None:
|
|
38
95
|
"""设置错误日志存储的根目录(通常为激活的工作空间路径)。
|
|
@@ -198,6 +255,12 @@ def collect_error(
|
|
|
198
255
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
199
256
|
json.dump(error_data, f, ensure_ascii=False, indent=2)
|
|
200
257
|
|
|
258
|
+
# GA 埋点:带 HTTP 状态码的报错同步累加进任务级内存聚合
|
|
259
|
+
if status_code and error_data["task_id"]:
|
|
260
|
+
_bump_upstream_error(
|
|
261
|
+
error_data["task_id"], status_code, model_type, api_method
|
|
262
|
+
)
|
|
263
|
+
|
|
201
264
|
# 优化路线图 1.5b:按数量轮转,防止 error_logs 无限膨胀
|
|
202
265
|
_rotate_error_logs(log_dir)
|
|
203
266
|
|
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.4"
|
|
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",
|