free-short-video 5.6.1 → 5.7.2

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/.dockerignore CHANGED
@@ -14,6 +14,9 @@ __pycache__/
14
14
  build/
15
15
  dist/
16
16
 
17
+ # 前端源码与依赖(构建产物已在 static/,镜像无需携带源码与 node_modules)
18
+ frontend/
19
+
17
20
  # 运行时产物与媒体(体积大,不进镜像)
18
21
  .working_dir/
19
22
  static/generated/
package/.env.example ADDED
@@ -0,0 +1,35 @@
1
+ # ═══════════════════════════════════════════════════════════════════
2
+ # Agnes Video Generator 配置模板(示例文件,绝不要放入真实 Key!)
3
+ #
4
+ # 使用方式:
5
+ # 1. 复制本文件为 .env:cp .env.example .env
6
+ # 2. 填入你自己的 Key(从 https://platform.agnes-ai.com 获取,免费)
7
+ # 3. 重启服务生效(需已安装 python-dotenv;未安装时跳过 .env 读取,
8
+ # Key 仍可通过环境变量或 Web 设置页 POST /api/config/keys 配置)
9
+ #
10
+ # 完整多 Key / 限速说明见 docs/plans/v5.0/optimization_roadmap.md §1
11
+ # ═══════════════════════════════════════════════════════════════════
12
+
13
+ # Agnes AI API Key(必填)
14
+ # 从 https://platform.agnes-ai.com 获取免费 Key
15
+ AGNES_API_KEY=your-api-key-here
16
+
17
+ # 多 Key 轮询(可选,见 optimization_roadmap §1.3)
18
+ # 每个 Key 的配额独立,总量 ≈ 20 × Key 数 / 分钟
19
+ # AGNES_API_KEY_2=your-second-api-key
20
+ # AGNES_API_KEY_3=your-third-api-key
21
+
22
+ # 限速配额覆盖(次/分钟,默认 = 20 × Key 数 × 0.8 安全系数)
23
+ # AGNES_RATE_LIMIT=160
24
+
25
+ # 桶容量覆盖(默认 = 4 × Key 数;仅需调节突发并发时使用)
26
+ # AGNES_RATE_BURST=32
27
+
28
+ # 视频提交独立限速(次/分钟,默认 = 1 × Key 数;服务端为全局 1/min 时设 =1)
29
+ # AGNES_VIDEO_RATE_LIMIT=8
30
+ # AGNES_VIDEO_RATE_BURST=8
31
+
32
+ # 可选端点/模型覆盖
33
+ # AGNES_BASE_URL=https://apihub.agnes-ai.com/v1
34
+ # AGNES_IMAGE_MODEL=agnes-image-2.1-flash
35
+ # AGNES_VIDEO_MODEL=agnes-video-v2.0
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
+ ---
2
+
3
+ # What's New in v5.7.2
4
+
5
+ ## What's New
6
+
7
+ ### Bug Fixes
8
+
9
+ - **Intermediate artifacts restored in the progress panel** — after the v5.7.0 frontend re-architecture, intermediate artifacts (story, reference images, scene videos, subtitles, etc.) were no longer shown while a task runs. The progress panel now loads artifacts on task start, refreshes them as each step completes during polling, and shows the final output when finished. Viewing a task from the task list also opens the progress panel with its artifacts and result video.
10
+ - **GitHub Code Scanning security fixes** — sensitive data no longer logged at startup (`get_api_keys_source`); key IDs are now derived with HMAC-SHA256 (blake2b keyed mode) instead of plain SHA-256; task-directory deletion/existence checks operate only on realpath-resolved paths (path-injection hardening).
11
+ - **Release pipeline fixes** — the npm README preparation step no longer fails when the format string starts with `-`, and the release workflow now tolerates missing release-notes docs with a warning instead of aborting.
12
+
13
+ ---
14
+
15
+ ---
16
+
1
17
  ## Quick Start (npm)
2
18
 
3
19
  ```bash
@@ -111,12 +127,12 @@ To be honest, Agnes's video model isn't perfect yet. The generated frames are so
111
127
 
112
128
  ## 📚 Documentation
113
129
 
114
- - **[Features](docs/features.md)** — Creation modes, the completely free AI model chain, AI narration & smart subtitles, flexible creative controls, production-grade reliability, and the multilingual Web UI.
115
- - **[Getting Started](docs/getting-started.md)** — Install and deploy in 4 ways: Manual (`start.sh`), Docker, npm (`npx free-short-video`), or AI-Agent assisted.
116
- - **[Usage Guide](docs/usage.md)** — Configure your API key, pick a video mode, resume from checkpoints, the three chaining modes, and logs & output layout.
117
- - **[Architecture](docs/architecture.md)** — Project structure and tech stack.
118
- - **[API Reference](docs/api.md)** — Full REST + WebSocket endpoint list.
119
- - **[FAQ](docs/faq.md)** — Frequently asked questions.
120
- - **[About & License](docs/about.md)** — Acknowledgments and the MIT license.
130
+ - **[Features](docs/public/features.md)** — Creation modes, the completely free AI model chain, AI narration & smart subtitles, flexible creative controls, production-grade reliability, and the multilingual Web UI.
131
+ - **[Getting Started](docs/public/getting-started.md)** — Install and deploy in 4 ways: Manual (`start.sh`), Docker, npm (`npx free-short-video`), or AI-Agent assisted.
132
+ - **[Usage Guide](docs/public/usage.md)** — Configure your API key, pick a video mode, resume from checkpoints, the three chaining modes, and logs & output layout.
133
+ - **[Architecture](docs/public/architecture.md)** — Project structure and tech stack.
134
+ - **[API Reference](docs/public/api.md)** — Full REST + WebSocket endpoint list.
135
+ - **[FAQ](docs/public/faq.md)** — Frequently asked questions.
136
+ - **[About & License](docs/public/about.md)** — Acknowledgments and the MIT license.
121
137
 
122
138
  **Keywords**: free AI video generator, AI video generation tool, text to video AI, free AI video maker, AI video creator, open source video generator, Agnes AI, text-to-video, image-to-video, keyframes video, AI narration, auto subtitles, multi-scene video, zero cost AI video, no subscription AI video tool, digital anchor, self-hosted AI video generator, open source alternative to Runway
@@ -16,11 +16,17 @@ from typing import List
16
16
  import requests
17
17
 
18
18
  from core.api.error_collector import collect_error, collect_error_from_exception
19
- from core.api.rate_limiter import get_rate_limiter
19
+ from core.api.rate_limiter import get_rate_limiter, request_with_key_rotation
20
20
  from core.config import get_agnes_base_url
21
21
 
22
22
  logger = logging.getLogger(__name__)
23
23
 
24
+ # json_repair 可选依赖(优化 4):未安装时行为与现状完全一致
25
+ try:
26
+ from json_repair import repair_json
27
+ except ImportError:
28
+ repair_json = None
29
+
24
30
  # 重试配置
25
31
  _MAX_RETRIES = 3
26
32
  _RETRY_BASE_DELAY = 15 # 秒,指数退避基数
@@ -57,10 +63,21 @@ class AgnesChatAPI:
57
63
  def __init__(self, api_key: str, model: str = "agnes-2.0-flash"):
58
64
  self.api_key = api_key
59
65
  self.model = model
60
- self.headers = {
61
- "Authorization": f"Bearer {api_key}",
66
+ # 基础 headers(不含 Authorization):每次请求前经 _auth_headers() 注入当前 Key
67
+ self._base_headers = {
62
68
  "Content-Type": "application/json",
63
69
  }
70
+ # 向后兼容:旧调用方可能读取 self.headers
71
+ self.headers = dict(self._base_headers)
72
+
73
+ def _auth_headers(self) -> dict:
74
+ """每次请求前生成带当前 Key 的 headers 副本(从 KeyRing 轮转取 Key)。"""
75
+ from core.api.key_manager import get_key_ring
76
+
77
+ key = get_key_ring().next()
78
+ h = dict(self._base_headers)
79
+ h["Authorization"] = f"Bearer {key}"
80
+ return h
64
81
 
65
82
  def _image_to_b64_uri(self, path: str) -> str:
66
83
  with open(path, "rb") as f:
@@ -89,10 +106,12 @@ class AgnesChatAPI:
89
106
  return ""
90
107
 
91
108
  def _request_with_retry(self, payload: dict, timeout: int = 120) -> dict:
92
- """带重试的 API 请求。
109
+ """带重试的 API 请求(429 换 Key + 5xx/超时/连接错误指数退避)。
93
110
 
94
- 对 5xx/429/超时/连接错误进行最多 3 次指数退避重试。
95
- 4xx 错误(非 429)直接抛出不重试。
111
+ 经 ``request_with_key_rotation`` 统一封装:
112
+ - 多 Key 下 429 换 Key 立即重试(不计入退避);
113
+ - 全 Key 429 / 5xx / 超时 / 连接错误 指数退避最多 3 次;
114
+ - 4xx(非 429)不重试,直接抛出。
96
115
  每次请求前通过全局限速器控制调用频率。
97
116
 
98
117
  Args:
@@ -105,75 +124,51 @@ class AgnesChatAPI:
105
124
  Raises:
106
125
  requests.HTTPError: 4xx 客户端错误或重试耗尽。
107
126
  """
108
- last_exc = None
109
- for attempt in range(_MAX_RETRIES):
110
- try:
111
- get_rate_limiter().acquire()
112
- resp = requests.post(
113
- f"{get_agnes_base_url()}/chat/completions",
114
- headers=self.headers,
115
- json=payload,
116
- timeout=timeout,
127
+ prompt = self._extract_prompt_from_payload(payload)
128
+ try:
129
+ get_rate_limiter().acquire()
130
+ resp = request_with_key_rotation(
131
+ requests.post,
132
+ f"{get_agnes_base_url()}/chat/completions",
133
+ max_retries=_MAX_RETRIES,
134
+ retry_base_delay=_RETRY_BASE_DELAY,
135
+ json=payload,
136
+ timeout=timeout,
137
+ )
138
+ if self._should_retry(resp):
139
+ # helper 内部重试已耗尽(单 Key 429 或持续 5xx),记录最终失败
140
+ collect_error(
141
+ "chat", "chat",
142
+ prompt=prompt,
143
+ error_type="RateLimit429" if resp.status_code == 429 else f"HTTP{resp.status_code}",
144
+ error_message=f"HTTP {resp.status_code}: retries exhausted",
145
+ status_code=resp.status_code,
146
+ response_body=resp.text,
147
+ retry_count=_MAX_RETRIES,
117
148
  )
118
- if self._should_retry(resp) and attempt < _MAX_RETRIES - 1:
119
- delay = _RETRY_BASE_DELAY * (attempt + 1)
120
- logger.warning(
121
- f"[AgnesChat] Server error {resp.status_code}, "
122
- f"retry {attempt + 1}/{_MAX_RETRIES} in {delay}s..."
123
- )
124
- collect_error(
125
- "chat", "chat",
126
- prompt=self._extract_prompt_from_payload(payload),
127
- error_type=f"HTTP{resp.status_code}",
128
- error_message=f"HTTP {resp.status_code}: server error",
129
- status_code=resp.status_code,
130
- response_body=resp.text,
131
- retry_count=attempt + 1,
132
- )
133
- time.sleep(delay)
134
- continue
135
- resp.raise_for_status()
136
- return resp.json()
137
- except (requests.ConnectionError, requests.Timeout) as e:
138
- last_exc = e
139
- # 每次失败都记录(包括中间重试)
140
- collect_error_from_exception(
149
+ resp.raise_for_status()
150
+ return resp.json()
151
+ except requests.HTTPError as e:
152
+ resp = getattr(e, "response", None)
153
+ if resp is not None and resp.status_code < 500 and resp.status_code != 429:
154
+ # 4xx(非 429)不可重试
155
+ collect_error(
141
156
  "chat", "chat",
142
- exc=e, prompt=self._extract_prompt_from_payload(payload),
143
- retry_count=attempt + 1,
157
+ prompt=prompt,
158
+ error_type=type(e).__name__,
159
+ error_message=str(e),
160
+ status_code=resp.status_code,
161
+ response_body=resp.text[:5000],
162
+ retry_count=_MAX_RETRIES,
144
163
  )
145
- if attempt < _MAX_RETRIES - 1:
146
- delay = _RETRY_BASE_DELAY * (attempt + 1)
147
- logger.warning(
148
- f"[AgnesChat] {type(e).__name__}, "
149
- f"retry {attempt + 1}/{_MAX_RETRIES} in {delay}s..."
150
- )
151
- time.sleep(delay)
152
- continue
153
- raise
154
- # 重试耗尽
155
- if last_exc:
164
+ raise
165
+ except (requests.ConnectionError, requests.Timeout) as e:
156
166
  collect_error_from_exception(
157
167
  "chat", "chat",
158
- exc=last_exc, prompt=self._extract_prompt_from_payload(payload),
159
- retry_count=_MAX_RETRIES,
160
- )
161
- raise last_exc
162
- # 不可重试的 HTTP 错误(4xx 非 429)
163
- try:
164
- resp.raise_for_status() # type: ignore[possibly-undefined]
165
- except requests.HTTPError as e:
166
- collect_error(
167
- "chat", "chat",
168
- prompt=self._extract_prompt_from_payload(payload),
169
- error_type=type(e).__name__,
170
- error_message=str(e),
171
- status_code=resp.status_code, # type: ignore[possibly-undefined]
172
- response_body=resp.text[:5000], # type: ignore[possibly-undefined]
168
+ exc=e, prompt=prompt,
173
169
  retry_count=_MAX_RETRIES,
174
170
  )
175
171
  raise
176
- return resp.json() # type: ignore[possibly-undefined]
177
172
 
178
173
  def chat(self, system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> str:
179
174
  """纯文本 Chat 调用(含重试)。"""
@@ -230,6 +225,15 @@ class AgnesChatAPI:
230
225
  return json.loads(match.group())
231
226
  except (json.JSONDecodeError, ValueError):
232
227
  pass
228
+ # Step 3.5: json_repair 修复(可选依赖,修复 LLM 常见缺冒号/尾随逗号/单引号)
229
+ if repair_json is not None:
230
+ try:
231
+ repaired = repair_json(cleaned, return_objects=True)
232
+ if isinstance(repaired, dict):
233
+ logger.info("[AgnesChat] JSON repaired via json_repair")
234
+ return repaired
235
+ except Exception:
236
+ pass
233
237
  # Step 4: 首轮失败则重试一次 chat 调用
234
238
  if retry == 0:
235
239
  logger.warning("[AgnesChat] JSON parse failed, retrying chat call...")
@@ -11,9 +11,11 @@ from typing import List, Optional
11
11
  import requests
12
12
 
13
13
  from core.api.error_collector import collect_error, collect_error_from_exception
14
+ from core.api.key_manager import get_key_ring
14
15
  from core.api.rate_limiter import get_rate_limiter
15
16
  from core.config import get_agnes_base_url
16
17
  from utils.image import download_image
18
+ from utils.image_normalizer import normalize_reference_path
17
19
 
18
20
  logger = logging.getLogger(__name__)
19
21
 
@@ -59,10 +61,19 @@ class AgnesImageAPI:
59
61
  # i2i 默认与 t2i 同模型(官方 2.1 同时支持 t2i/i2i);环境变量可回退到 2.0。
60
62
  env_i2i = os.environ.get("AGNES_IMAGE_I2I_MODEL")
61
63
  self.i2i_model = i2i_model or env_i2i or model
62
- self.headers = {
63
- "Authorization": f"Bearer {api_key}",
64
+ # 基础 headers(不含 Authorization):每次请求前经 _auth_headers() 注入当前 Key
65
+ self._base_headers = {
64
66
  "Content-Type": "application/json",
65
67
  }
68
+ # 向后兼容:旧调用方可能读取 self.headers
69
+ self.headers = dict(self._base_headers)
70
+
71
+ def _auth_headers(self) -> dict:
72
+ """每次请求前生成带当前 Key 的 headers 副本(从 KeyRing 轮转取 Key)。"""
73
+ key = get_key_ring().next()
74
+ h = dict(self._base_headers)
75
+ h["Authorization"] = f"Bearer {key}"
76
+ return h
66
77
 
67
78
  async def _path_to_b64(self, path: str) -> str:
68
79
  def _read():
@@ -102,7 +113,20 @@ class AgnesImageAPI:
102
113
  payload["negative_prompt"] = kwargs["negative_prompt"]
103
114
 
104
115
  if reference_image_paths:
105
- resolved = [await self._resolve_image_ref(p) for p in reference_image_paths]
116
+ # 优化 2:入参处先归一化参考图(尺寸统一 + 体积压缩),再 resolve。
117
+ # 目标尺寸从 size("768x1152")解析,失败回退 1024x1024。
118
+ sw, sh = 1024, 1024
119
+ try:
120
+ parts = (size or "").lower().split("x")
121
+ if len(parts) == 2:
122
+ sw, sh = int(parts[0]), int(parts[1])
123
+ except (ValueError, TypeError):
124
+ sw, sh = 1024, 1024
125
+ normalized_paths = []
126
+ for p in reference_image_paths:
127
+ norm = await asyncio.to_thread(normalize_reference_path, p, sw, sh)
128
+ normalized_paths.append(norm)
129
+ resolved = [await self._resolve_image_ref(p) for p in normalized_paths]
106
130
  # 官方文档所有 i2i 示例均用 image 数组形式(extra_body.image=[url]),
107
131
  # 单图也统一传数组,保持与官方协议一致。
108
132
  payload["extra_body"] = {
@@ -113,7 +137,11 @@ class AgnesImageAPI:
113
137
  logger.info(f"[AgnesImage] Generating ({'i2i' if use_i2i else 't2i'}): {prompt[:80]}...")
114
138
 
115
139
  resp = None
116
- for attempt in range(max_retries):
140
+ attempt = 0
141
+ rotations = 0
142
+ ring = get_key_ring()
143
+ max_rotations = len(ring) * max_retries
144
+ while attempt < max_retries:
117
145
  try:
118
146
  # 全局限速:在发起 HTTP 请求前获取令牌
119
147
  await asyncio.to_thread(get_rate_limiter().acquire)
@@ -122,29 +150,51 @@ class AgnesImageAPI:
122
150
  resp = await asyncio.to_thread(
123
151
  requests.post,
124
152
  f"{get_agnes_base_url()}/images/generations",
125
- headers=self.headers,
153
+ headers=self._auth_headers(),
126
154
  json=payload,
127
155
  timeout=(30, read_timeout),
128
156
  )
129
157
 
130
- # 429 限流:退避重试
131
- if resp.status_code == 429 and attempt < max_retries - 1:
132
- delay = retry_base_delay * (attempt + 1)
133
- logger.warning(
134
- f"[AgnesImage] 429 rate limit, "
135
- f"retry {attempt + 1}/{max_retries} in {delay:.0f}s..."
136
- )
158
+ # 429 限流:多 Key 换 Key 立即重试;否则指数退避
159
+ if resp.status_code == 429:
160
+ if ring.has_multiple() and rotations < max_rotations:
161
+ rotations += 1
162
+ ring.rotate()
163
+ logger.warning(
164
+ f"[KeyRotation] HTTP 429, 换 Key 立即重试 "
165
+ f"(image, rotation {rotations})"
166
+ )
167
+ continue
168
+ if attempt < max_retries - 1:
169
+ delay = retry_base_delay * (attempt + 1)
170
+ logger.warning(
171
+ f"[AgnesImage] 429 rate limit, "
172
+ f"retry {attempt + 1}/{max_retries} in {delay:.0f}s..."
173
+ )
174
+ collect_error(
175
+ "image", "generate_single_image",
176
+ prompt=prompt,
177
+ error_type="RateLimit429",
178
+ error_message=f"HTTP 429: rate limited",
179
+ status_code=429,
180
+ response_body=resp.text,
181
+ retry_count=attempt + 1,
182
+ )
183
+ await asyncio.sleep(delay)
184
+ attempt += 1
185
+ continue
186
+ # 退避耗尽:记录最终失败并抛出
137
187
  collect_error(
138
188
  "image", "generate_single_image",
139
189
  prompt=prompt,
140
190
  error_type="RateLimit429",
141
- error_message=f"HTTP 429: rate limited",
191
+ error_message=f"HTTP 429: retries exhausted",
142
192
  status_code=429,
143
193
  response_body=resp.text,
144
- retry_count=attempt + 1,
194
+ retry_count=max_retries,
145
195
  )
146
- await asyncio.sleep(delay)
147
- continue
196
+ resp.raise_for_status()
197
+ break
148
198
 
149
199
  # 5xx 服务端错误:退避重试
150
200
  if resp.status_code >= 500 and attempt < max_retries - 1:
@@ -163,6 +213,7 @@ class AgnesImageAPI:
163
213
  retry_count=attempt + 1,
164
214
  )
165
215
  await asyncio.sleep(delay)
216
+ attempt += 1
166
217
  continue
167
218
 
168
219
  if resp.status_code != 200:
@@ -183,6 +234,7 @@ class AgnesImageAPI:
183
234
  f"retry {attempt + 1}/{max_retries} in {delay:.0f}s..."
184
235
  )
185
236
  await asyncio.sleep(delay)
237
+ attempt += 1
186
238
  continue
187
239
  raise
188
240
  else:
@@ -12,8 +12,10 @@ from typing import List, Optional
12
12
  import requests
13
13
 
14
14
  from core.api.error_collector import collect_error, collect_error_from_exception
15
- from core.api.rate_limiter import get_rate_limiter
15
+ from core.api.key_manager import get_key_ring
16
+ from core.api.rate_limiter import get_rate_limiter, get_video_submit_limiter
16
17
  from core.config import get_agnes_base_url, get_agnes_api_root
18
+ from utils.image_normalizer import normalize_reference_path
17
19
  from utils.video import download_video
18
20
 
19
21
  logger = logging.getLogger(__name__)
@@ -61,10 +63,19 @@ class AgnesVideoAPI:
61
63
  self.max_retries = max_retries
62
64
  self.retry_base_delay = retry_base_delay
63
65
  self.shutdown_event = None
64
- self.headers = {
65
- "Authorization": f"Bearer {api_key}",
66
+ # 基础 headers(不含 Authorization):每次请求前经 _auth_headers() 注入当前 Key
67
+ self._base_headers = {
66
68
  "Content-Type": "application/json",
67
69
  }
70
+ # 向后兼容:旧调用方可能读取 self.headers
71
+ self.headers = dict(self._base_headers)
72
+
73
+ def _auth_headers(self) -> dict:
74
+ """每次请求前生成带当前 Key 的 headers 副本(从 KeyRing 轮转取 Key)。"""
75
+ key = get_key_ring().next()
76
+ h = dict(self._base_headers)
77
+ h["Authorization"] = f"Bearer {key}"
78
+ return h
68
79
 
69
80
  def _path_to_b64(self, path: str) -> str:
70
81
  with open(path, "rb") as f:
@@ -121,7 +132,11 @@ class AgnesVideoAPI:
121
132
  return ref
122
133
 
123
134
  async def _upload_image_to_url(self, image_path: str, retries: int = 3) -> Optional[str]:
124
- for attempt in range(retries):
135
+ attempt = 0
136
+ rotations = 0
137
+ ring = get_key_ring()
138
+ max_rotations = len(ring) * retries
139
+ while attempt < retries:
125
140
  if self.shutdown_event and self.shutdown_event.is_set():
126
141
  logger.info("[AgnesVideo] Image upload cancelled by shutdown")
127
142
  return None
@@ -142,14 +157,23 @@ class AgnesVideoAPI:
142
157
  resp = await asyncio.to_thread(
143
158
  requests.post,
144
159
  f"{get_agnes_base_url()}/images/generations",
145
- headers=self.headers,
160
+ headers=self._auth_headers(),
146
161
  json=payload,
147
162
  timeout=(30, 120),
148
163
  )
149
164
  if resp.status_code == 429:
165
+ if ring.has_multiple() and rotations < max_rotations:
166
+ rotations += 1
167
+ ring.rotate()
168
+ logger.warning(
169
+ f"[KeyRotation] HTTP 429, 换 Key 立即重试 "
170
+ f"(upload, rotation {rotations})"
171
+ )
172
+ continue
150
173
  delay = _UPLOAD_RETRY_BASE_DELAY_SECONDS * (attempt + 1)
151
174
  logger.warning(f"[AgnesVideo] Image upload 429, retry in {delay}s...")
152
175
  await asyncio.sleep(delay)
176
+ attempt += 1
153
177
  continue
154
178
  resp.raise_for_status()
155
179
  result = resp.json()
@@ -241,16 +265,24 @@ class AgnesVideoAPI:
241
265
  logger.info(f"[AgnesVideo] Polling video {video_id[:16]}... (poll #{poll_count + 1}, elapsed {elapsed:.0f}s)")
242
266
  # 全局限速:每次轮询都消耗一个令牌
243
267
  await asyncio.to_thread(get_rate_limiter().acquire)
244
- # M2: 用 wait_for 包裹以支持取消
245
- resp = await asyncio.wait_for(
246
- asyncio.to_thread(
247
- requests.get,
248
- f"{get_agnes_api_root()}/agnesapi?video_id={video_id}",
249
- headers=self.headers,
250
- timeout=15,
251
- ),
252
- timeout=30,
253
- )
268
+ # M2: 用 wait_for 包裹以支持取消;429 换 Key 立即重试(轮询也轮转 Key 分摊配额)
269
+ poll_attempts = 0
270
+ while True:
271
+ resp = await asyncio.wait_for(
272
+ asyncio.to_thread(
273
+ requests.get,
274
+ f"{get_agnes_api_root()}/agnesapi?video_id={video_id}",
275
+ headers=self._auth_headers(),
276
+ timeout=15,
277
+ ),
278
+ timeout=30,
279
+ )
280
+ if resp.status_code == 429 and get_key_ring().has_multiple() and poll_attempts < 5:
281
+ get_key_ring().rotate()
282
+ poll_attempts += 1
283
+ logger.warning("[KeyRotation] HTTP 429 on poll, 换 Key 立即重试")
284
+ continue
285
+ break
254
286
  resp.raise_for_status()
255
287
  result = resp.json()
256
288
  status = result.get("status", "")
@@ -309,19 +341,23 @@ class AgnesVideoAPI:
309
341
 
310
342
  async def _submit_with_retry(self, payload: dict, mode_desc: str) -> str:
311
343
  frame_reductions_left = 2 # allow up to 2 frame-count reductions on 400
312
- for attempt in range(self.max_retries):
344
+ attempt = 0
345
+ rotations = 0
346
+ ring = get_key_ring()
347
+ max_rotations = len(ring) * self.max_retries
348
+ while attempt < self.max_retries:
313
349
  if self.shutdown_event and self.shutdown_event.is_set():
314
350
  raise RuntimeError("Video generation cancelled by user")
315
351
  try:
316
352
  logger.info(f"[AgnesVideo] Submitting {mode_desc} (attempt {attempt + 1}/{self.max_retries})...")
317
- # 全局限速:在发起提交请求前获取令牌
318
- await asyncio.to_thread(get_rate_limiter().acquire)
353
+ # 视频提交独立限速桶(服务端 1/min 硬限制,不与 chat/image 共享配额)
354
+ await asyncio.to_thread(get_video_submit_limiter().acquire)
319
355
  # M2: 缩短读超时从 180s 到 60s,使 stop() 更快生效
320
356
  resp = await asyncio.wait_for(
321
357
  asyncio.to_thread(
322
358
  requests.post,
323
359
  f"{get_agnes_base_url()}/videos",
324
- headers=self.headers,
360
+ headers=self._auth_headers(),
325
361
  json=payload,
326
362
  timeout=(15, 60),
327
363
  ),
@@ -335,6 +371,15 @@ class AgnesVideoAPI:
335
371
  return video_id
336
372
 
337
373
  if resp.status_code == 429:
374
+ # 多 Key:换 Key 立即重试(不 sleep、不计入退避)
375
+ if ring.has_multiple() and rotations < max_rotations:
376
+ rotations += 1
377
+ ring.rotate()
378
+ logger.warning(
379
+ f"[KeyRotation] HTTP 429 on submit, 换 Key 立即重试 "
380
+ f"(rotation {rotations})"
381
+ )
382
+ continue
338
383
  delay = self.retry_base_delay * (attempt + 1)
339
384
  logger.warning(
340
385
  f"[AgnesVideo] 429 rate limit on {mode_desc}, "
@@ -351,6 +396,7 @@ class AgnesVideoAPI:
351
396
  extra={"mode": mode_desc},
352
397
  )
353
398
  await asyncio.sleep(delay)
399
+ attempt += 1
354
400
  continue
355
401
 
356
402
  if resp.status_code >= 500:
@@ -370,6 +416,7 @@ class AgnesVideoAPI:
370
416
  extra={"mode": mode_desc},
371
417
  )
372
418
  await asyncio.sleep(delay)
419
+ attempt += 1
373
420
  continue
374
421
 
375
422
  # HTTP 400 with num_frames exceeded → reduce frames and retry
@@ -427,6 +474,7 @@ class AgnesVideoAPI:
427
474
  f"retry {attempt + 1}/{self.max_retries} in {delay:.0f}s..."
428
475
  )
429
476
  await asyncio.sleep(delay)
477
+ attempt += 1
430
478
  continue
431
479
  raise
432
480
 
@@ -495,7 +543,10 @@ class AgnesVideoAPI:
495
543
 
496
544
  resolved_refs = []
497
545
  for p in reference_image_paths:
498
- resolved_refs.append(await self._resolve_image_ref(p))
546
+ # 优化 2:入参处先归一化参考图(尺寸统一 + 体积压缩),再 resolve。
547
+ # URL/data: 透传、失败回退原图,安全无回归。
548
+ norm = await asyncio.to_thread(normalize_reference_path, p, width, height)
549
+ resolved_refs.append(await self._resolve_image_ref(norm))
499
550
  n_refs = len(resolved_refs)
500
551
 
501
552
  if n_refs == 0: