free-short-video 5.1.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 +32 -0
- package/LICENSE +21 -0
- package/README.md +531 -0
- package/bin/cli.js +191 -0
- package/core/__init__.py +35 -0
- package/core/api/__init__.py +7 -0
- package/core/api/agnes_chat.py +294 -0
- package/core/api/agnes_image.py +256 -0
- package/core/api/agnes_models.py +84 -0
- package/core/api/agnes_video.py +535 -0
- package/core/api/error_collector.py +215 -0
- package/core/api/rate_limiter.py +142 -0
- package/core/artifacts.py +574 -0
- package/core/audio/__init__.py +9 -0
- package/core/audio/subtitle.py +1103 -0
- package/core/audio/tts.py +194 -0
- package/core/audio/voices.py +330 -0
- package/core/compositor/__init__.py +6 -0
- package/core/compositor/concatenator.py +653 -0
- package/core/compositor/processor.py +124 -0
- package/core/compositor/watermark.py +221 -0
- package/core/config.py +474 -0
- package/core/image_generator.py +8 -0
- package/core/path_security.py +58 -0
- package/core/pipeline.py +8 -0
- package/core/pipelines/__init__.py +466 -0
- package/core/pipelines/anchor_video.py +406 -0
- package/core/pipelines/creative_video.py +1968 -0
- package/core/pipelines/manuscript_video.py +540 -0
- package/core/pipelines/multi_scene.py +380 -0
- package/core/pipelines/poetry_video.py +504 -0
- package/core/pipelines/simple_video.py +143 -0
- package/core/screenwriter.py +1860 -0
- package/core/task_manager.py +211 -0
- package/core/video_generator.py +8 -0
- package/models/__init__.py +75 -0
- package/models/task.py +548 -0
- package/package.json +36 -0
- package/requirements.txt +13 -0
- package/resource/fonts/MicrosoftYaHeiNormal.ttc +0 -0
- package/resource/fonts/STHeitiMedium.ttc +0 -0
- package/server.py +2026 -0
- package/static/favicon.ico +0 -0
- package/static/icon.png +0 -0
- package/static/index.html +5277 -0
- package/utils/__init__.py +0 -0
- package/utils/image.py +36 -0
- package/utils/video.py +28 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""core.api.error_collector — 模型接口报错收集模块
|
|
2
|
+
|
|
3
|
+
收集 Agnes 文本/图片/视频模型 API 调用失败时的报错信息,
|
|
4
|
+
包含 prompt、错误类型、错误详情等,存储在工作目录下的 error_logs/ 中。
|
|
5
|
+
|
|
6
|
+
使用方式:
|
|
7
|
+
from core.api.error_collector import collect_error, collect_error_from_exception
|
|
8
|
+
|
|
9
|
+
# 方式一:手动构造
|
|
10
|
+
collect_error(model_type="image", api_method="generate_single_image",
|
|
11
|
+
prompt="一只猫", error_type="ConnectionError",
|
|
12
|
+
error_message="Connection timed out", retry_count=3)
|
|
13
|
+
|
|
14
|
+
# 方式二:从异常对象收集(自动提取 HTTPError 响应体中的 API 错误详情)
|
|
15
|
+
collect_error_from_exception("chat", "chat", exc, prompt="你好")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import requests as _requests_lib
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_WORKSPACE_ROOT: Optional[Path] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_workspace_root(path: str) -> None:
|
|
33
|
+
"""设置错误日志存储的根目录(通常为激活的工作空间路径)。
|
|
34
|
+
|
|
35
|
+
应在服务启动时调用一次。如果不设置,则回退到 server.py 所在目录。
|
|
36
|
+
"""
|
|
37
|
+
global _WORKSPACE_ROOT
|
|
38
|
+
_WORKSPACE_ROOT = Path(path).resolve()
|
|
39
|
+
logger.info(f"[ErrorCollector] Workspace root set to {_WORKSPACE_ROOT}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_workspace_root() -> Path:
|
|
43
|
+
"""获取存储根目录。
|
|
44
|
+
|
|
45
|
+
优先级:
|
|
46
|
+
1. set_workspace_root() 显式设置
|
|
47
|
+
2. 自动检测 server.py 所在目录
|
|
48
|
+
"""
|
|
49
|
+
global _WORKSPACE_ROOT
|
|
50
|
+
if _WORKSPACE_ROOT is not None:
|
|
51
|
+
return _WORKSPACE_ROOT
|
|
52
|
+
current = Path(os.getcwd()).resolve()
|
|
53
|
+
for parent in [current] + list(current.parents):
|
|
54
|
+
if (parent / "server.py").exists():
|
|
55
|
+
_WORKSPACE_ROOT = parent
|
|
56
|
+
break
|
|
57
|
+
if _WORKSPACE_ROOT is None:
|
|
58
|
+
_WORKSPACE_ROOT = current
|
|
59
|
+
return _WORKSPACE_ROOT
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_log_dir() -> Path:
|
|
63
|
+
"""获取 error_logs 目录路径,不存在则创建。"""
|
|
64
|
+
log_dir = _get_workspace_root() / "error_logs"
|
|
65
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
return log_dir
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_from_http_error(exc: Exception) -> tuple[Optional[int], str, str]:
|
|
70
|
+
"""从 HTTPError 异常中提取状态码、响应体、以及 API 级错误详情。
|
|
71
|
+
|
|
72
|
+
返回 (status_code, response_body, enhanced_message)。
|
|
73
|
+
如果 exc 不是 HTTPError 或提取失败,返回 (None, "", str(exc))。
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
if isinstance(exc, _requests_lib.exceptions.HTTPError):
|
|
77
|
+
resp = getattr(exc, "response", None)
|
|
78
|
+
if resp is not None:
|
|
79
|
+
sc = resp.status_code
|
|
80
|
+
body = resp.text or ""
|
|
81
|
+
enhanced = str(exc)
|
|
82
|
+
|
|
83
|
+
# 尝试从 JSON 响应体中提取 API 级错误详情
|
|
84
|
+
if body.strip():
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(body)
|
|
87
|
+
err = data.get("error", {})
|
|
88
|
+
api_msg = err.get("message", "") if isinstance(err, dict) else str(err)
|
|
89
|
+
api_type = err.get("type", "") if isinstance(err, dict) else ""
|
|
90
|
+
if api_msg:
|
|
91
|
+
# 优先使用 API 级错误消息,避免重复拼接 type
|
|
92
|
+
if api_type and api_msg.startswith(api_type):
|
|
93
|
+
# message 已含 type 前缀,不再重复
|
|
94
|
+
enhanced = f"{api_msg} (HTTP {sc})"
|
|
95
|
+
elif api_type:
|
|
96
|
+
enhanced = f"{api_type}: {api_msg} (HTTP {sc})"
|
|
97
|
+
else:
|
|
98
|
+
enhanced = f"{api_msg} (HTTP {sc})"
|
|
99
|
+
else:
|
|
100
|
+
enhanced = enhanced + " | body=" + body[:500]
|
|
101
|
+
except (json.JSONDecodeError, ValueError):
|
|
102
|
+
enhanced = enhanced + " | body=" + body[:500]
|
|
103
|
+
|
|
104
|
+
return sc, body, enhanced
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
return None, "", str(exc)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def collect_error(
|
|
111
|
+
model_type: str,
|
|
112
|
+
api_method: str,
|
|
113
|
+
prompt: str = "",
|
|
114
|
+
system_prompt: str = "",
|
|
115
|
+
error_type: str = "",
|
|
116
|
+
error_message: str = "",
|
|
117
|
+
status_code: Optional[int] = None,
|
|
118
|
+
response_body: str = "",
|
|
119
|
+
retry_count: int = 0,
|
|
120
|
+
extra: Optional[dict] = None,
|
|
121
|
+
) -> Optional[str]:
|
|
122
|
+
"""收集一次 API 调用错误并写入 error_logs/ 目录。
|
|
123
|
+
|
|
124
|
+
返回保存的文件路径,如果收集过程自身出错则返回 None(不影响主流程)。
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
model_type: 模型类型,如 "chat", "image", "video"。
|
|
128
|
+
api_method: 调用的方法名,如 "chat", "generate_single_image", "submit_video"。
|
|
129
|
+
prompt: 发送给模型的 prompt(超长自动截断至 5000 字符)。
|
|
130
|
+
system_prompt: system 提示词(仅 chat 类,截断至 2000 字符)。
|
|
131
|
+
error_type: 错误类型名称,如 "ConnectionError", "HTTPError", "RuntimeError"。
|
|
132
|
+
error_message: 错误消息原文。
|
|
133
|
+
status_code: HTTP 状态码(如有)。
|
|
134
|
+
response_body: 响应体原文(截断至 5000 字符)。
|
|
135
|
+
retry_count: 失败前已尝试的重试次数。
|
|
136
|
+
extra: 额外结构化信息(如 video_id, mode 等)。
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
log_dir = _get_log_dir()
|
|
140
|
+
now = datetime.now()
|
|
141
|
+
filename = (
|
|
142
|
+
now.strftime("%Y-%m-%d_%H-%M-%S-%f")
|
|
143
|
+
+ f"_{model_type}_{api_method}.json"
|
|
144
|
+
)
|
|
145
|
+
filepath = log_dir / filename
|
|
146
|
+
|
|
147
|
+
# 限制字段长度,避免日志文件过大
|
|
148
|
+
error_data = {
|
|
149
|
+
"timestamp": now.isoformat(),
|
|
150
|
+
"model_type": model_type,
|
|
151
|
+
"api_method": api_method,
|
|
152
|
+
"prompt": prompt[:5000] if prompt else "",
|
|
153
|
+
"system_prompt": system_prompt[:2000] if system_prompt else "",
|
|
154
|
+
"error_type": error_type,
|
|
155
|
+
"error_message": error_message[:3000] if error_message else "",
|
|
156
|
+
"status_code": status_code,
|
|
157
|
+
"response_body": response_body[:5000] if response_body else "",
|
|
158
|
+
"retry_count": retry_count,
|
|
159
|
+
}
|
|
160
|
+
if extra:
|
|
161
|
+
error_data["extra"] = extra
|
|
162
|
+
|
|
163
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
164
|
+
json.dump(error_data, f, ensure_ascii=False, indent=2)
|
|
165
|
+
|
|
166
|
+
logger.info(f"[ErrorCollector] Error saved → {filepath}")
|
|
167
|
+
return str(filepath)
|
|
168
|
+
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.error(f"[ErrorCollector] Failed to collect error: {e}")
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def collect_error_from_exception(
|
|
175
|
+
model_type: str,
|
|
176
|
+
api_method: str,
|
|
177
|
+
exc: Exception,
|
|
178
|
+
prompt: str = "",
|
|
179
|
+
system_prompt: str = "",
|
|
180
|
+
status_code: Optional[int] = None,
|
|
181
|
+
response_body: str = "",
|
|
182
|
+
retry_count: int = 0,
|
|
183
|
+
extra: Optional[dict] = None,
|
|
184
|
+
) -> Optional[str]:
|
|
185
|
+
"""便捷包装:从异常对象自动提取 error_type 和 error_message。
|
|
186
|
+
|
|
187
|
+
对于 requests.HTTPError,会自动从 exc.response 中提取状态码和响应体,
|
|
188
|
+
并尝试解析 JSON 获取 API 级错误详情(如 content_policy_violation)。
|
|
189
|
+
如果调用方已传入 status_code / response_body,则优先使用传入值。
|
|
190
|
+
"""
|
|
191
|
+
error_type = type(exc).__name__
|
|
192
|
+
error_msg = str(exc)
|
|
193
|
+
|
|
194
|
+
# 自动提取 HTTPError 的响应信息(仅在未显式传入时填充)
|
|
195
|
+
extracted_sc, extracted_body, extracted_msg = _extract_from_http_error(exc)
|
|
196
|
+
if status_code is None:
|
|
197
|
+
status_code = extracted_sc
|
|
198
|
+
if not response_body:
|
|
199
|
+
response_body = extracted_body
|
|
200
|
+
# 如果有 API 级错误详情,优先使用
|
|
201
|
+
if extracted_msg != str(exc):
|
|
202
|
+
error_msg = extracted_msg
|
|
203
|
+
|
|
204
|
+
return collect_error(
|
|
205
|
+
model_type=model_type,
|
|
206
|
+
api_method=api_method,
|
|
207
|
+
prompt=prompt,
|
|
208
|
+
system_prompt=system_prompt,
|
|
209
|
+
error_type=error_type,
|
|
210
|
+
error_message=error_msg,
|
|
211
|
+
status_code=status_code,
|
|
212
|
+
response_body=response_body,
|
|
213
|
+
retry_count=retry_count,
|
|
214
|
+
extra=extra,
|
|
215
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""core.api.rate_limiter — Agnes API 全局限速器(令牌桶算法)
|
|
2
|
+
|
|
3
|
+
所有 Agnes API 调用(Chat / Image / Video,含轮询)共享同一个令牌桶,
|
|
4
|
+
确保总调用频率不超过 Agnes API 的 20 次/分钟限制。
|
|
5
|
+
|
|
6
|
+
用法::
|
|
7
|
+
|
|
8
|
+
from core.api.rate_limiter import get_rate_limiter
|
|
9
|
+
|
|
10
|
+
# 在发起 HTTP 请求前获取令牌(同步,可能阻塞)
|
|
11
|
+
limiter = get_rate_limiter()
|
|
12
|
+
limiter.acquire()
|
|
13
|
+
resp = requests.post(url, ...)
|
|
14
|
+
|
|
15
|
+
# 异步版本(内部用 to_thread 包装)
|
|
16
|
+
await limiter.acquire_async()
|
|
17
|
+
|
|
18
|
+
环境变量:
|
|
19
|
+
AGNES_RATE_LIMIT: 每分钟最大调用次数,默认 20
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Agnes API 所有接口共享的每分钟调用上限
|
|
31
|
+
_AGNES_RATE_LIMIT = int(os.environ.get("AGNES_RATE_LIMIT", "20"))
|
|
32
|
+
# 预留 20% 余量:实际允许 80% 的配额,即 16 次/分钟(3.75 秒/次)
|
|
33
|
+
_SAFETY_FACTOR = 0.8
|
|
34
|
+
_EFFECTIVE_RATE = _AGNES_RATE_LIMIT * _SAFETY_FACTOR # 16 次/分钟
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AgnesRateLimiter:
|
|
38
|
+
"""令牌桶限速器(线程安全)。
|
|
39
|
+
|
|
40
|
+
在多线程 / asyncio.to_thread / 纯同步场景下均可安全使用。
|
|
41
|
+
当令牌不足时,``acquire()`` 会阻塞当前线程直到令牌可用。
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
max_tokens: 桶容量(突发上限)。
|
|
45
|
+
refill_rate: 每秒补充的令牌数。
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, rate_per_minute: float = _EFFECTIVE_RATE,
|
|
49
|
+
max_burst: int = 4):
|
|
50
|
+
"""初始化限速器。
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
rate_per_minute: 每分钟允许的调用次数。
|
|
54
|
+
max_burst: 令牌桶最大容量(允许短时突发)。
|
|
55
|
+
"""
|
|
56
|
+
self.max_tokens = min(max_burst, rate_per_minute)
|
|
57
|
+
self.refill_rate = rate_per_minute / 60.0 # tokens per second
|
|
58
|
+
self.tokens = float(self.max_tokens)
|
|
59
|
+
self.last_refill = time.monotonic()
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
self._total_waits = 0
|
|
62
|
+
self._total_wait_seconds = 0.0
|
|
63
|
+
|
|
64
|
+
def acquire(self) -> None:
|
|
65
|
+
"""阻塞式获取一个令牌。
|
|
66
|
+
|
|
67
|
+
如果桶中有令牌,立即消耗并返回。
|
|
68
|
+
否则计算等待时间并 ``time.sleep()`` 直到令牌可用。
|
|
69
|
+
"""
|
|
70
|
+
with self._lock:
|
|
71
|
+
now = time.monotonic()
|
|
72
|
+
elapsed = now - self.last_refill
|
|
73
|
+
self.tokens = min(
|
|
74
|
+
self.max_tokens,
|
|
75
|
+
self.tokens + elapsed * self.refill_rate,
|
|
76
|
+
)
|
|
77
|
+
self.last_refill = now
|
|
78
|
+
|
|
79
|
+
if self.tokens >= 1.0:
|
|
80
|
+
self.tokens -= 1.0
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# 需要等待的时间
|
|
84
|
+
wait_time = (1.0 - self.tokens) / self.refill_rate
|
|
85
|
+
self.tokens = 0.0
|
|
86
|
+
# 更新 refill 时间基准,防止 sleep 期间令牌被其他线程"偷走"
|
|
87
|
+
self.last_refill = now + wait_time
|
|
88
|
+
|
|
89
|
+
# sleep 在锁外执行,避免阻塞其他线程的 refill 计算
|
|
90
|
+
if wait_time > 0.05:
|
|
91
|
+
self._total_waits += 1
|
|
92
|
+
self._total_wait_seconds += wait_time
|
|
93
|
+
logger.info(
|
|
94
|
+
f"[RateLimiter] 限速等待 {wait_time:.1f}s "
|
|
95
|
+
f"(累计等待 {self._total_waits} 次, "
|
|
96
|
+
f"{self._total_wait_seconds:.0f}s)"
|
|
97
|
+
)
|
|
98
|
+
time.sleep(wait_time)
|
|
99
|
+
|
|
100
|
+
async def acquire_async(self) -> None:
|
|
101
|
+
"""异步获取令牌(内部使用 ``asyncio.to_thread``)。"""
|
|
102
|
+
await asyncio.to_thread(self.acquire)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def stats(self) -> dict:
|
|
106
|
+
"""返回限速器统计信息。"""
|
|
107
|
+
return {
|
|
108
|
+
"total_waits": self._total_waits,
|
|
109
|
+
"total_wait_seconds": round(self._total_wait_seconds, 1),
|
|
110
|
+
"effective_rate_per_min": round(_EFFECTIVE_RATE, 1),
|
|
111
|
+
"max_burst": self.max_tokens,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ═══════════════════════════════════════════════════
|
|
116
|
+
# 全局单例
|
|
117
|
+
# ═══════════════════════════════════════════════════
|
|
118
|
+
|
|
119
|
+
_instance: AgnesRateLimiter | None = None
|
|
120
|
+
_instance_lock = threading.Lock()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_rate_limiter() -> AgnesRateLimiter:
|
|
124
|
+
"""获取全局速率限制器实例(线程安全单例)。"""
|
|
125
|
+
global _instance
|
|
126
|
+
if _instance is None:
|
|
127
|
+
with _instance_lock:
|
|
128
|
+
if _instance is None:
|
|
129
|
+
_instance = AgnesRateLimiter()
|
|
130
|
+
logger.info(
|
|
131
|
+
f"[RateLimiter] 初始化: {_EFFECTIVE_RATE:.0f} 次/分钟 "
|
|
132
|
+
f"(原始限制 {_AGNES_RATE_LIMIT}, 安全系数 {_SAFETY_FACTOR}), "
|
|
133
|
+
f"突发上限 {_instance.max_tokens}"
|
|
134
|
+
)
|
|
135
|
+
return _instance
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def reset_rate_limiter() -> None:
|
|
139
|
+
"""重置全局限速器(仅用于测试)。"""
|
|
140
|
+
global _instance
|
|
141
|
+
with _instance_lock:
|
|
142
|
+
_instance = None
|