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,256 @@
1
+ """core.api.agnes_image — Agnes Image API 封装(从 core/image_generator.py 迁移)"""
2
+
3
+ import asyncio
4
+ import base64
5
+ import logging
6
+ import mimetypes
7
+ import os
8
+ import time
9
+ from typing import List, Optional
10
+
11
+ import requests
12
+
13
+ from core.api.error_collector import collect_error, collect_error_from_exception
14
+ from core.api.rate_limiter import get_rate_limiter
15
+ from utils.image import download_image
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ BASE_URL = "https://apihub.agnes-ai.com/v1"
20
+
21
+
22
+ class ImageOutput:
23
+ def __init__(self, fmt: str, ext: str, data: str):
24
+ self.fmt = fmt
25
+ self.ext = ext
26
+ self.data = data
27
+
28
+ def save(self, path: str) -> None:
29
+ if self.fmt == "url":
30
+ download_image(self.data, path)
31
+ else:
32
+ raw = self.data.split(",")[1] if "," in self.data else self.data
33
+ with open(path, "wb") as f:
34
+ f.write(base64.b64decode(raw))
35
+
36
+
37
+ class AgnesImageAPI:
38
+ """Agnes Image 生成 API 封装(t2i / i2i)。"""
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str,
43
+ model: str = "agnes-image-2.1-flash",
44
+ i2i_model: Optional[str] = None,
45
+ ):
46
+ """初始化图片 API。
47
+
48
+ Args:
49
+ api_key: Agnes API Key。
50
+ model: t2i 默认模型。
51
+ i2i_model: i2i 默认模型。默认与 ``model`` 相同(官方 agnes-image-2.1-flash
52
+ 同时支持 t2i 与 i2i)。如需回退到 2.0,可通过环境变量
53
+ ``AGNES_IMAGE_I2I_MODEL`` 或显式传参覆盖。
54
+ """
55
+ self.api_key = api_key
56
+ self.model = model
57
+ # i2i 默认与 t2i 同模型(官方 2.1 同时支持 t2i/i2i);环境变量可回退到 2.0。
58
+ env_i2i = os.environ.get("AGNES_IMAGE_I2I_MODEL")
59
+ self.i2i_model = i2i_model or env_i2i or model
60
+ self.headers = {
61
+ "Authorization": f"Bearer {api_key}",
62
+ "Content-Type": "application/json",
63
+ }
64
+
65
+ async def _path_to_b64(self, path: str) -> str:
66
+ def _read():
67
+ with open(path, "rb") as f:
68
+ return base64.b64encode(f.read()).decode("utf-8")
69
+
70
+ b64 = await asyncio.to_thread(_read)
71
+ mime = mimetypes.guess_type(path)[0] or "image/png"
72
+ return f"data:{mime};base64,{b64}"
73
+
74
+ async def _resolve_image_ref(self, ref: str) -> str:
75
+ if ref.startswith(("http://", "https://", "data:")):
76
+ return ref
77
+ if os.path.exists(ref):
78
+ return await self._path_to_b64(ref)
79
+ return ref
80
+
81
+ async def generate_single_image(
82
+ self,
83
+ prompt: str,
84
+ reference_image_paths: List[str] = [],
85
+ size: Optional[str] = None,
86
+ max_retries: int = 3,
87
+ retry_base_delay: float = 20.0,
88
+ **kwargs,
89
+ ) -> ImageOutput:
90
+ use_i2i = len(reference_image_paths) > 0
91
+ model = self.i2i_model if use_i2i else self.model
92
+ payload: dict = {
93
+ "model": model,
94
+ "prompt": prompt,
95
+ "size": size or "1024x1024",
96
+ "n": 1,
97
+ }
98
+
99
+ if kwargs.get("negative_prompt"):
100
+ payload["negative_prompt"] = kwargs["negative_prompt"]
101
+
102
+ if reference_image_paths:
103
+ resolved = [await self._resolve_image_ref(p) for p in reference_image_paths]
104
+ # 官方文档所有 i2i 示例均用 image 数组形式(extra_body.image=[url]),
105
+ # 单图也统一传数组,保持与官方协议一致。
106
+ payload["extra_body"] = {
107
+ "response_format": "url",
108
+ "image": resolved,
109
+ }
110
+
111
+ logger.info(f"[AgnesImage] Generating ({'i2i' if use_i2i else 't2i'}): {prompt[:80]}...")
112
+
113
+ resp = None
114
+ for attempt in range(max_retries):
115
+ try:
116
+ # 全局限速:在发起 HTTP 请求前获取令牌
117
+ await asyncio.to_thread(get_rate_limiter().acquire)
118
+ # 动态超时:第一次 60s,后续逐步增加
119
+ read_timeout = 60 * (attempt + 1)
120
+ resp = await asyncio.to_thread(
121
+ requests.post,
122
+ f"{BASE_URL}/images/generations",
123
+ headers=self.headers,
124
+ json=payload,
125
+ timeout=(30, read_timeout),
126
+ )
127
+
128
+ # 429 限流:退避重试
129
+ if resp.status_code == 429 and attempt < max_retries - 1:
130
+ delay = retry_base_delay * (attempt + 1)
131
+ logger.warning(
132
+ f"[AgnesImage] 429 rate limit, "
133
+ f"retry {attempt + 1}/{max_retries} in {delay:.0f}s..."
134
+ )
135
+ collect_error(
136
+ "image", "generate_single_image",
137
+ prompt=prompt,
138
+ error_type="RateLimit429",
139
+ error_message=f"HTTP 429: rate limited",
140
+ status_code=429,
141
+ response_body=resp.text,
142
+ retry_count=attempt + 1,
143
+ )
144
+ await asyncio.sleep(delay)
145
+ continue
146
+
147
+ # 5xx 服务端错误:退避重试
148
+ if resp.status_code >= 500 and attempt < max_retries - 1:
149
+ delay = retry_base_delay * (attempt + 1)
150
+ logger.warning(
151
+ f"[AgnesImage] {resp.status_code} server error, "
152
+ f"retry {attempt + 1}/{max_retries} in {delay:.0f}s..."
153
+ )
154
+ collect_error(
155
+ "image", "generate_single_image",
156
+ prompt=prompt,
157
+ error_type=f"HTTP{resp.status_code}",
158
+ error_message=f"HTTP {resp.status_code}: server error",
159
+ status_code=resp.status_code,
160
+ response_body=resp.text,
161
+ retry_count=attempt + 1,
162
+ )
163
+ await asyncio.sleep(delay)
164
+ continue
165
+
166
+ if resp.status_code != 200:
167
+ logger.error(f"[AgnesImage] Non-retryable error: HTTP {resp.status_code}, body: {resp.text[:500]}")
168
+ resp.raise_for_status()
169
+ break
170
+
171
+ except (requests.ConnectionError, requests.Timeout) as e:
172
+ # 每次失败都记录(包括中间重试)
173
+ collect_error_from_exception(
174
+ "image", "generate_single_image",
175
+ exc=e, prompt=prompt, retry_count=attempt + 1,
176
+ )
177
+ if attempt < max_retries - 1:
178
+ delay = retry_base_delay * (attempt + 1)
179
+ logger.warning(
180
+ f"[AgnesImage] {type(e).__name__}, "
181
+ f"retry {attempt + 1}/{max_retries} in {delay:.0f}s..."
182
+ )
183
+ await asyncio.sleep(delay)
184
+ continue
185
+ raise
186
+ else:
187
+ # 重试耗尽
188
+ if resp is not None:
189
+ logger.error(f"[AgnesImage] max retries exceeded, last response: {resp.status_code} {resp.text[:500]}")
190
+ collect_error(
191
+ "image", "generate_single_image",
192
+ prompt=prompt,
193
+ error_type="HTTPError",
194
+ error_message=f"Max retries exceeded, last status: {resp.status_code}",
195
+ status_code=resp.status_code,
196
+ response_body=resp.text,
197
+ retry_count=max_retries,
198
+ )
199
+ resp.raise_for_status()
200
+ logger.error(f"[AgnesImage] max retries ({max_retries}) exceeded with no response")
201
+ collect_error(
202
+ "image", "generate_single_image",
203
+ prompt=prompt,
204
+ error_type="RetriesExhausted",
205
+ error_message=f"Max retries ({max_retries}) exceeded with no response",
206
+ retry_count=max_retries,
207
+ )
208
+ raise RuntimeError(
209
+ f"[AgnesImage] max retries ({max_retries}) exceeded"
210
+ )
211
+
212
+ result = resp.json()
213
+
214
+ if "error" in result:
215
+ err = result["error"]
216
+ error_msg = f"Agnes image error: {err.get('message', err)}"
217
+ collect_error(
218
+ "image", "generate_single_image",
219
+ prompt=prompt,
220
+ error_type="APIError",
221
+ error_message=error_msg,
222
+ response_body=resp.text,
223
+ retry_count=attempt + 1 if resp else max_retries,
224
+ )
225
+ raise RuntimeError(error_msg)
226
+
227
+ data_list = result.get("data", [])
228
+ if not data_list:
229
+ collect_error(
230
+ "image", "generate_single_image",
231
+ prompt=prompt,
232
+ error_type="NoDataError",
233
+ error_message="Agnes image: no data returned",
234
+ response_body=resp.text,
235
+ retry_count=attempt + 1 if resp else max_retries,
236
+ )
237
+ raise RuntimeError("Agnes image: no data returned")
238
+
239
+ url = data_list[0].get("url", "")
240
+ if not url:
241
+ b64_data = data_list[0].get("b64_json", "")
242
+ if b64_data:
243
+ logger.info("[AgnesImage] Got base64 response, saving...")
244
+ return ImageOutput(fmt="b64", ext="png", data=b64_data)
245
+ collect_error(
246
+ "image", "generate_single_image",
247
+ prompt=prompt,
248
+ error_type="NoOutputError",
249
+ error_message="Agnes image: no URL or base64 in response",
250
+ response_body=resp.text,
251
+ retry_count=attempt + 1 if resp else max_retries,
252
+ )
253
+ raise RuntimeError("Agnes image: no URL or base64 in response")
254
+
255
+ logger.info(f"[AgnesImage] Done: {url[:80]}...")
256
+ return ImageOutput(fmt="url", ext="png", data=url)
@@ -0,0 +1,84 @@
1
+ """core.api.agnes_models — 拉取 Agnes 可用模型列表(v5.0)
2
+
3
+ 封装 GET /v1/models?all=true,按模型 ID 前缀分组为 text/image/video 三类。
4
+ 接口失败(网络/鉴权/非 200)时回退到硬编码默认列表,保证 UI 始终可用。
5
+ """
6
+
7
+ import logging
8
+
9
+ import requests
10
+
11
+ from core.api.agnes_chat import BASE_URL
12
+ from core.config import (
13
+ DEFAULT_TEXT_MODEL,
14
+ DEFAULT_IMAGE_MODEL,
15
+ DEFAULT_VIDEO_MODEL,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # 带 beta 开关的模型列表端点:裸 /v1/models 会被网关过滤掉内测模型,
21
+ # 加 ?all=true(等价 X-Beta-Access: true / ?include_beta=true)才会返回全量(含 2.5-flash)。
22
+ MODELS_ENDPOINT = f"{BASE_URL}/models?all=true"
23
+
24
+ REQUEST_TIMEOUT = 20
25
+
26
+ # 分组失败时的兜底列表
27
+ _FALLBACK = {
28
+ "text": [DEFAULT_TEXT_MODEL],
29
+ "image": [DEFAULT_IMAGE_MODEL],
30
+ "video": [DEFAULT_VIDEO_MODEL],
31
+ }
32
+
33
+
34
+ def _classify(model_id: str) -> str:
35
+ """根据模型 ID 前缀判断分组。
36
+
37
+ - ``agnes-image*`` → image
38
+ - ``agnes-video*`` → video
39
+ - 其余(如 ``agnes-2.0-flash`` / ``agnes-2.5-flash``)→ text
40
+ """
41
+ if model_id.startswith("agnes-image"):
42
+ return "image"
43
+ if model_id.startswith("agnes-video"):
44
+ return "video"
45
+ return "text"
46
+
47
+
48
+ def fetch_available_models(api_key: str) -> dict:
49
+ """拉取并按类型分组 Agnes 可用模型。
50
+
51
+ Args:
52
+ api_key: Agnes API Key(Bearer Token)。
53
+
54
+ Returns:
55
+ {"text": [model_id, ...], "image": [...], "video": [...]}
56
+ 接口失败(网络/鉴权/非 200)时返回硬编码兜底列表。
57
+ """
58
+ try:
59
+ resp = requests.get(
60
+ MODELS_ENDPOINT,
61
+ headers={"Authorization": f"Bearer {api_key}"},
62
+ timeout=REQUEST_TIMEOUT,
63
+ )
64
+ if resp.status_code != 200:
65
+ logger.warning(
66
+ "[AgnesModels] /v1/models returned HTTP %s, using fallback",
67
+ resp.status_code,
68
+ )
69
+ return dict(_FALLBACK)
70
+ data = resp.json()
71
+ grouped = {"text": [], "image": [], "video": []}
72
+ for item in data.get("data", []):
73
+ mid = item.get("id")
74
+ if not mid:
75
+ continue
76
+ grouped[_classify(mid)].append(mid)
77
+ # 任一分类为空则补回默认,避免 UI 空下拉
78
+ for k, default in _FALLBACK.items():
79
+ if not grouped[k]:
80
+ grouped[k] = list(default)
81
+ return grouped
82
+ except Exception as e: # noqa: BLE001
83
+ logger.warning("[AgnesModels] fetch failed (%s), using fallback", e)
84
+ return dict(_FALLBACK)