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.
Files changed (48) hide show
  1. package/.dockerignore +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +531 -0
  4. package/bin/cli.js +191 -0
  5. package/core/__init__.py +35 -0
  6. package/core/api/__init__.py +7 -0
  7. package/core/api/agnes_chat.py +294 -0
  8. package/core/api/agnes_image.py +256 -0
  9. package/core/api/agnes_models.py +84 -0
  10. package/core/api/agnes_video.py +535 -0
  11. package/core/api/error_collector.py +215 -0
  12. package/core/api/rate_limiter.py +142 -0
  13. package/core/artifacts.py +574 -0
  14. package/core/audio/__init__.py +9 -0
  15. package/core/audio/subtitle.py +1103 -0
  16. package/core/audio/tts.py +194 -0
  17. package/core/audio/voices.py +330 -0
  18. package/core/compositor/__init__.py +6 -0
  19. package/core/compositor/concatenator.py +653 -0
  20. package/core/compositor/processor.py +124 -0
  21. package/core/compositor/watermark.py +221 -0
  22. package/core/config.py +474 -0
  23. package/core/image_generator.py +8 -0
  24. package/core/path_security.py +58 -0
  25. package/core/pipeline.py +8 -0
  26. package/core/pipelines/__init__.py +466 -0
  27. package/core/pipelines/anchor_video.py +406 -0
  28. package/core/pipelines/creative_video.py +1968 -0
  29. package/core/pipelines/manuscript_video.py +540 -0
  30. package/core/pipelines/multi_scene.py +380 -0
  31. package/core/pipelines/poetry_video.py +504 -0
  32. package/core/pipelines/simple_video.py +143 -0
  33. package/core/screenwriter.py +1860 -0
  34. package/core/task_manager.py +211 -0
  35. package/core/video_generator.py +8 -0
  36. package/models/__init__.py +75 -0
  37. package/models/task.py +548 -0
  38. package/package.json +36 -0
  39. package/requirements.txt +13 -0
  40. package/resource/fonts/MicrosoftYaHeiNormal.ttc +0 -0
  41. package/resource/fonts/STHeitiMedium.ttc +0 -0
  42. package/server.py +2026 -0
  43. package/static/favicon.ico +0 -0
  44. package/static/icon.png +0 -0
  45. package/static/index.html +5277 -0
  46. package/utils/__init__.py +0 -0
  47. package/utils/image.py +36 -0
  48. 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