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.
Files changed (60) hide show
  1. package/README.md +9 -7
  2. package/core/api/agnes_video.py +306 -13
  3. package/core/api/error_collector.py +63 -0
  4. package/core/config.py +18 -5
  5. package/core/i18n_backend.py +13 -0
  6. package/core/pipelines/__init__.py +52 -1
  7. package/core/pipelines/anchor_video.py +2 -0
  8. package/core/pipelines/creative/steps_video.py +15 -0
  9. package/core/pipelines/multi_scene.py +17 -3
  10. package/core/pipelines/simple_video.py +17 -3
  11. package/models/task.py +9 -0
  12. package/package.json +1 -1
  13. package/static/assets/ar-BGwnHyw2.js +1 -0
  14. package/static/assets/bn-BZ1SVmZv.js +1 -0
  15. package/static/assets/de-CUKZPeV-.js +1 -0
  16. package/static/assets/es-DKx-teoF.js +1 -0
  17. package/static/assets/fa-DTgnsV-R.js +1 -0
  18. package/static/assets/fr-QDOIUFKu.js +1 -0
  19. package/static/assets/hi-CiRTbxUR.js +1 -0
  20. package/static/assets/id-DVJD5Y5W.js +1 -0
  21. package/static/assets/index-KzCkI4Os.js +45 -0
  22. package/static/assets/it-BsQ3SshU.js +1 -0
  23. package/static/assets/ja-D9O41FnB.js +1 -0
  24. package/static/assets/ko-BH0PhAjf.js +1 -0
  25. package/static/assets/ms-qcRbwWeG.js +1 -0
  26. package/static/assets/nl-BuXaX-G6.js +1 -0
  27. package/static/assets/pt-CC3c_p8Q.js +1 -0
  28. package/static/assets/ru-CYIQhw4j.js +1 -0
  29. package/static/assets/th-BHjOht4_.js +1 -0
  30. package/static/assets/tl-CLPCfL8g.js +1 -0
  31. package/static/assets/tr-_PXfKikt.js +1 -0
  32. package/static/assets/ur-CNslvoMb.js +1 -0
  33. package/static/assets/vi-BCoL1tPB.js +1 -0
  34. package/static/index.html +1 -1
  35. package/utils/network.py +52 -0
  36. package/web/routes/config_routes.py +11 -1
  37. package/web/routes/task_routes.py +6 -0
  38. package/web/routes/utility_routes.py +5 -1
  39. package/web/routes/video_routes.py +7 -0
  40. package/static/assets/ar-CRbirOE3.js +0 -1
  41. package/static/assets/bn-DGXpxXqo.js +0 -1
  42. package/static/assets/de-CW_QJIGx.js +0 -1
  43. package/static/assets/es-DG3zPtKB.js +0 -1
  44. package/static/assets/fa-KkBv8EdH.js +0 -1
  45. package/static/assets/fr-zdohXdVm.js +0 -1
  46. package/static/assets/hi-g1ky89XQ.js +0 -1
  47. package/static/assets/id-C21bK9DP.js +0 -1
  48. package/static/assets/index-DNreiP_P.js +0 -45
  49. package/static/assets/it-C5Xbey_b.js +0 -1
  50. package/static/assets/ja-NciKY87B.js +0 -1
  51. package/static/assets/ko-DJ8XhzBo.js +0 -1
  52. package/static/assets/ms-DcwfkMJG.js +0 -1
  53. package/static/assets/nl-CnpYVEZB.js +0 -1
  54. package/static/assets/pt-DAP0auKP.js +0 -1
  55. package/static/assets/ru-CzKYIeBy.js +0 -1
  56. package/static/assets/th-Q_FQzUg5.js +0 -1
  57. package/static/assets/tl-DXi6nq4V.js +0 -1
  58. package/static/assets/tr-BOCBaAw1.js +0 -1
  59. package/static/assets/ur-K0JNi0pO.js +0 -1
  60. 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.2
3
+ # What's New in v7.0.4
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
+ * **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 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.
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 you were hitting `401`, re-check your key against its site in the API Key panel or run *Auto-detect domains*.
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
 
@@ -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
- err = result.get("error") or "unknown error"
370
- error_msg = f"Video generation failed: {err}"
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=error_msg,
564
+ error_message=message,
376
565
  response_body=resp.text,
377
- extra={"video_id": video_id[:16], "status": status},
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) -> 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=f"HTTP {resp.status_code}: server error",
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
- poll_timeout = get_settings().agnes_video_poll_timeout
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
- return VideoOutput(fmt="url", ext="mp4", data=video_url)
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.2"
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
  }
@@ -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",