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.
Files changed (57) hide show
  1. package/README.md +10 -7
  2. package/core/api/agnes_video.py +288 -13
  3. package/core/config.py +18 -5
  4. package/core/i18n_backend.py +13 -0
  5. package/core/pipelines/__init__.py +52 -1
  6. package/core/pipelines/anchor_video.py +2 -0
  7. package/core/pipelines/creative/steps_video.py +15 -0
  8. package/core/pipelines/multi_scene.py +17 -3
  9. package/core/pipelines/simple_video.py +17 -3
  10. package/models/task.py +9 -0
  11. package/package.json +1 -1
  12. package/static/assets/ar-BGwnHyw2.js +1 -0
  13. package/static/assets/bn-BZ1SVmZv.js +1 -0
  14. package/static/assets/de-CUKZPeV-.js +1 -0
  15. package/static/assets/es-DKx-teoF.js +1 -0
  16. package/static/assets/fa-DTgnsV-R.js +1 -0
  17. package/static/assets/fr-QDOIUFKu.js +1 -0
  18. package/static/assets/hi-CiRTbxUR.js +1 -0
  19. package/static/assets/id-DVJD5Y5W.js +1 -0
  20. package/static/assets/index-BC30W-Gs.js +45 -0
  21. package/static/assets/it-BsQ3SshU.js +1 -0
  22. package/static/assets/ja-D9O41FnB.js +1 -0
  23. package/static/assets/ko-BH0PhAjf.js +1 -0
  24. package/static/assets/ms-qcRbwWeG.js +1 -0
  25. package/static/assets/nl-BuXaX-G6.js +1 -0
  26. package/static/assets/pt-CC3c_p8Q.js +1 -0
  27. package/static/assets/ru-CYIQhw4j.js +1 -0
  28. package/static/assets/th-BHjOht4_.js +1 -0
  29. package/static/assets/tl-CLPCfL8g.js +1 -0
  30. package/static/assets/tr-_PXfKikt.js +1 -0
  31. package/static/assets/ur-CNslvoMb.js +1 -0
  32. package/static/assets/vi-BCoL1tPB.js +1 -0
  33. package/static/index.html +1 -1
  34. package/utils/network.py +52 -0
  35. package/web/routes/config_routes.py +11 -1
  36. package/web/routes/utility_routes.py +5 -1
  37. package/static/assets/ar-CRbirOE3.js +0 -1
  38. package/static/assets/bn-DGXpxXqo.js +0 -1
  39. package/static/assets/de-CW_QJIGx.js +0 -1
  40. package/static/assets/es-DG3zPtKB.js +0 -1
  41. package/static/assets/fa-KkBv8EdH.js +0 -1
  42. package/static/assets/fr-zdohXdVm.js +0 -1
  43. package/static/assets/hi-g1ky89XQ.js +0 -1
  44. package/static/assets/id-C21bK9DP.js +0 -1
  45. package/static/assets/index-DNreiP_P.js +0 -45
  46. package/static/assets/it-C5Xbey_b.js +0 -1
  47. package/static/assets/ja-NciKY87B.js +0 -1
  48. package/static/assets/ko-DJ8XhzBo.js +0 -1
  49. package/static/assets/ms-DcwfkMJG.js +0 -1
  50. package/static/assets/nl-CnpYVEZB.js +0 -1
  51. package/static/assets/pt-DAP0auKP.js +0 -1
  52. package/static/assets/ru-CzKYIeBy.js +0 -1
  53. package/static/assets/th-Q_FQzUg5.js +0 -1
  54. package/static/assets/tl-DXi6nq4V.js +0 -1
  55. package/static/assets/tr-BOCBaAw1.js +0 -1
  56. package/static/assets/ur-K0JNi0pO.js +0 -1
  57. 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.2
3
+ # What's New in v7.0.3
4
4
 
5
5
  ## What's New
6
6
 
7
7
  ### Features & Improvements
8
8
 
9
- * **Clearer key/domain setup to prevent `401`.** The API Key panel now states, per source, that keys added in the web UI can be bound to their own domain (and are covered by *Auto-detect domains*), while `.env` / environment-variable keys always follow the single **global default domain** and cannot be bound individually. Each `.env` key now shows a neutral "follows global domain" badge with the resolved endpoint. A new **multi-key** note explains that a single mismatched or expired key makes tasks intermittently fail with `401` whenever that key is picked. Getting Started and the FAQ gained a "match the key to the right site" section, and `.env.example` now spells out the `.env`-key behavior — so single-site users set the domain and mixed-site users move keys into the web config page.
10
- * **True 16:9 landscape.** Landscape (16:9) video now renders at the correct aspect ratio, and the portrait / large-landscape resolution option labels were fixed so the listed size matches what is actually generated.
11
- * **Refreshed language switcher.** The native browser language dropdown was replaced with a site-styled language switcher, consistent with the rest of the UI across all 22 locales.
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
- * Fixed the config page recommending "bind a matching domain for each key" on `.env`-sourced keys, which have no domain dropdown and cannot be bound — the misleading hint that repeatedly sent users down the wrong path when chasing `401` errors. The *Auto-detect domains* tooltip now correctly states it only probes web-UI keys.
16
- * Corrected the Docker/compose image reference to the real flat GHCR namespace, so documented pull commands actually resolve.
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 you were hitting `401`, re-check your key against its site in the API Key panel or run *Auto-detect domains*.
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
 
@@ -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
- err = result.get("error") or "unknown error"
370
- error_msg = f"Video generation failed: {err}"
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=error_msg,
546
+ error_message=message,
376
547
  response_body=resp.text,
377
- extra={"video_id": video_id[:16], "status": status},
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) -> 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=f"HTTP {resp.status_code}: server error",
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
- poll_timeout = get_settings().agnes_video_poll_timeout
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
- return VideoOutput(fmt="url", ext="mp4", data=video_url)
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.2"
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
  }
@@ -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