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 +3 -0
- package/.env.example +35 -0
- package/README.md +23 -7
- package/core/api/agnes_chat.py +71 -67
- package/core/api/agnes_image.py +68 -16
- package/core/api/agnes_video.py +71 -20
- package/core/api/key_manager.py +88 -0
- package/core/api/rate_limiter.py +173 -24
- package/core/artifacts.py +200 -0
- package/core/audio/voices.py +1 -1
- package/core/config.py +209 -11
- package/core/pipelines/__init__.py +29 -0
- package/core/pipelines/anchor_video.py +2 -0
- package/core/pipelines/creative/steps_audio.py +4 -2
- package/core/pipelines/creative/steps_frames.py +11 -20
- package/core/pipelines/creative/steps_video.py +24 -1
- package/core/pipelines/manuscript_video.py +2 -0
- package/core/pipelines/multi_scene.py +3 -1
- package/core/pipelines/poetry_video.py +4 -0
- package/models/task.py +4 -0
- package/package.json +1 -1
- package/requirements.txt +5 -0
- package/start.bat +79 -0
- package/static/assets/index-C-Z5weAF.css +1 -0
- package/static/assets/index-DvwFKz3J.js +30 -0
- package/static/index.html +25 -8798
- package/utils/image_normalizer.py +146 -0
- package/web/app_state.py +5 -0
- package/web/deps.py +14 -0
- package/web/routes/config_routes.py +187 -1
- package/web/routes/task_creation_routes.py +12 -0
- package/web/routes/video_routes.py +116 -0
package/.dockerignore
CHANGED
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
|
package/core/api/agnes_chat.py
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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=
|
|
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...")
|
package/core/api/agnes_image.py
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
191
|
+
error_message=f"HTTP 429: retries exhausted",
|
|
142
192
|
status_code=429,
|
|
143
193
|
response_body=resp.text,
|
|
144
|
-
retry_count=
|
|
194
|
+
retry_count=max_retries,
|
|
145
195
|
)
|
|
146
|
-
|
|
147
|
-
|
|
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:
|
package/core/api/agnes_video.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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:
|