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,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)
|