free-short-video 6.5.0 → 6.5.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/.env.example +4 -0
- package/README.md +7 -5
- package/core/api/agnes_chat.py +18 -3
- package/core/api/rate_limiter.py +16 -3
- package/core/config.py +3 -1
- package/core/gallery_cache.py +60 -0
- package/core/pipelines/creative/pipeline.py +19 -1
- package/core/pipelines/manuscript_video.py +45 -8
- package/core/presets.py +159 -0
- package/package.json +1 -1
- package/resource/presets/styles.json +92 -0
- package/server.py +4 -0
- package/start.bat +20 -8
- package/static/assets/ar-CvuF8gFF.js +1 -0
- package/static/assets/bn-CWLD7QCi.js +1 -0
- package/static/assets/de-DOVh35jv.js +1 -0
- package/static/assets/es-CLzRtmI7.js +1 -0
- package/static/assets/fa-DNTSi8NJ.js +1 -0
- package/static/assets/fr-BpUCsjaC.js +1 -0
- package/static/assets/hi-Bkg-ypdW.js +1 -0
- package/static/assets/id-CJjRdrXY.js +1 -0
- package/static/assets/index-CgXb4MrZ.css +1 -0
- package/static/assets/index-Dn7-Q6vG.js +45 -0
- package/static/assets/it-CyVEADj1.js +1 -0
- package/static/assets/ja-yWWPg0BT.js +1 -0
- package/static/assets/ko-BbbGMI2i.js +1 -0
- package/static/assets/ms-CJ1UCmU_.js +1 -0
- package/static/assets/nl-7OqowihO.js +1 -0
- package/static/assets/pt-C8Z7vWM8.js +1 -0
- package/static/assets/ru-BJpeOLhl.js +1 -0
- package/static/assets/th-BOYCdRJN.js +1 -0
- package/static/assets/tl-DzOYHZWC.js +1 -0
- package/static/assets/tr-IYge5J2a.js +1 -0
- package/static/assets/ur-C24aPBX9.js +1 -0
- package/static/assets/vi-BfnnHjVU.js +1 -0
- package/static/index.html +2 -2
- package/web/routes/gallery_routes.py +167 -0
- package/web/routes/preset_routes.py +65 -0
- package/web/routes/video_routes.py +7 -2
- package/static/assets/ar-KHCrxRA_.js +0 -1
- package/static/assets/bn-Cdg9UtGd.js +0 -1
- package/static/assets/de-DpVpIIzl.js +0 -1
- package/static/assets/es-CbqgNYrf.js +0 -1
- package/static/assets/fa-eXTzpP2j.js +0 -1
- package/static/assets/fr-SR1IWdla.js +0 -1
- package/static/assets/hi-DrHcqlna.js +0 -1
- package/static/assets/id-DkV63qNl.js +0 -1
- package/static/assets/index-CGsvTiYV.css +0 -1
- package/static/assets/index-CL0ZNqqM.js +0 -45
- package/static/assets/it-Tos-zjmh.js +0 -1
- package/static/assets/ja-CbiPQ4QC.js +0 -1
- package/static/assets/ko-BO0zBA93.js +0 -1
- package/static/assets/ms-DnDGKrps.js +0 -1
- package/static/assets/nl-DaVWNF5X.js +0 -1
- package/static/assets/pt-BDbdLcbe.js +0 -1
- package/static/assets/ru-CuVlJkcC.js +0 -1
- package/static/assets/th-B_U6YxGQ.js +0 -1
- package/static/assets/tl-Dzn1t6Fh.js +0 -1
- package/static/assets/tr-DBSQCQnY.js +0 -1
- package/static/assets/ur-BpwQWAf9.js +0 -1
- package/static/assets/vi-CfYDhyzE.js +0 -1
package/.env.example
CHANGED
|
@@ -84,6 +84,10 @@ PORT=8765
|
|
|
84
84
|
# 视频任务轮询总超时(秒,默认 1800)
|
|
85
85
|
# AGNES_VIDEO_POLL_TIMEOUT=1800
|
|
86
86
|
|
|
87
|
+
# Chat 单次读超时(秒,默认 300;与多模态 chat_multimodal 对齐,消除双标准)
|
|
88
|
+
# stability_hardening P1:超时类错误最多重试 1 次,扩大成功窗口的同时压缩失败等待
|
|
89
|
+
# AGNES_CHAT_TIMEOUT=300
|
|
90
|
+
|
|
87
91
|
# 字幕合成:=0 关闭 ffmpeg ASS 单链(回退 moviepy 词级动效路径),默认开启
|
|
88
92
|
# AGNES_SUBTITLE_ASS=1
|
|
89
93
|
|
package/README.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
|
|
3
|
-
# What's New in v6.5.
|
|
3
|
+
# What's New in v6.5.2
|
|
4
4
|
|
|
5
5
|
## What's New
|
|
6
6
|
|
|
7
7
|
### Features & Improvements
|
|
8
8
|
|
|
9
|
-
* **
|
|
10
|
-
* **
|
|
11
|
-
* **
|
|
9
|
+
* **Style / prompt preset library.** Creative and poetry video forms now offer a style picker beside the style input. It ships with 18 curated, non-overlapping presets across six categories (photographic, cinematic, oriental, painting, animation, fantasy), each structured as medium + lighting + color + texture to compose cleanly with your subject prompt. Clicking a preset fills the style box in one click and you can keep editing it. Saved as a preset is supported too.
|
|
10
|
+
* **One-click preset reuse with controls.** The picker lists your custom presets first, brings recently used presets to the front, shows the full prompt on hover, and lets you save the current style edit as a named custom preset. System presets are read-only; custom presets can be renamed and deleted.
|
|
11
|
+
* **Media gallery (read-only).** A new parallel "Gallery" tab shows finished videos and images in a filterable grid (type × status). Video cards use the native first frame as a cover with hover playback, and images link to the existing media endpoint. Clicking a card opens the task detail. The gallery is strictly read-only — it exposes no delete or batch operations — and reads entirely from the existing task directory, so no new data files are written into task folders.
|
|
12
|
+
* **Lazy thumbnail cache (gallery).** For larger galleries, video covers are extracted to a rebuildable cache under the config directory (never inside task folders) to keep the grid snappy without touching the task directory structure.
|
|
13
|
+
* **Localization.** All preset category names and system-preset display names are translated across all 22 supported interface languages; the English and Chinese key sets are fully aligned.
|
|
12
14
|
|
|
13
15
|
### Bug Fixes
|
|
14
16
|
|
|
15
|
-
*
|
|
17
|
+
* Creative and poetry forms no longer pre-fill the style field with hardcoded Chinese text on non-Chinese interfaces; an empty value is now a valid choice, matching the backend default.
|
|
16
18
|
|
|
17
19
|
---
|
|
18
20
|
|
package/core/api/agnes_chat.py
CHANGED
|
@@ -109,7 +109,10 @@ class AgnesChatAPI:
|
|
|
109
109
|
return content
|
|
110
110
|
return ""
|
|
111
111
|
|
|
112
|
-
def _request_with_retry(
|
|
112
|
+
def _request_with_retry(
|
|
113
|
+
self, payload: dict, timeout: int = 120,
|
|
114
|
+
timeout_retry_limit: int | None = None,
|
|
115
|
+
) -> dict:
|
|
113
116
|
"""带重试的 API 请求(429 换 Key + 5xx/超时/连接错误指数退避)。
|
|
114
117
|
|
|
115
118
|
经 ``request_with_key_rotation`` 统一封装:
|
|
@@ -121,6 +124,7 @@ class AgnesChatAPI:
|
|
|
121
124
|
Args:
|
|
122
125
|
payload: 请求 JSON body。
|
|
123
126
|
timeout: 请求超时秒数。
|
|
127
|
+
timeout_retry_limit: 超时/连接类错误的最大重试次数(None = 沿用 3)。
|
|
124
128
|
|
|
125
129
|
Returns:
|
|
126
130
|
解析后的响应 JSON dict。
|
|
@@ -136,6 +140,7 @@ class AgnesChatAPI:
|
|
|
136
140
|
"/chat/completions",
|
|
137
141
|
max_retries=_MAX_RETRIES,
|
|
138
142
|
retry_base_delay=_RETRY_BASE_DELAY,
|
|
143
|
+
timeout_retry_limit=timeout_retry_limit,
|
|
139
144
|
json=payload,
|
|
140
145
|
timeout=timeout,
|
|
141
146
|
)
|
|
@@ -175,8 +180,17 @@ class AgnesChatAPI:
|
|
|
175
180
|
raise
|
|
176
181
|
|
|
177
182
|
def chat(self, system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> str:
|
|
178
|
-
"""纯文本 Chat 调用(含重试)。
|
|
183
|
+
"""纯文本 Chat 调用(含重试)。
|
|
184
|
+
|
|
185
|
+
stability_hardening P1:单次读超时读 ``AGNES_CHAT_TIMEOUT``(默认 300,
|
|
186
|
+
与 chat_multimodal 对齐,消除双标准);超时类错误最多重试 1 次
|
|
187
|
+
(``timeout_retry_limit=1``),使"成功窗口从 120s 扩到 300s"的同时
|
|
188
|
+
最坏等待时长不再翻倍(约 10.3 分钟,与现状相当)。5xx / 429 重试不受影响。
|
|
189
|
+
"""
|
|
179
190
|
logger.info(f"[AgnesChat] Calling chat ({self.model}), prompt: {len(user_prompt)} chars...")
|
|
191
|
+
from core.config import get_settings
|
|
192
|
+
|
|
193
|
+
timeout_retry_limit = 1
|
|
180
194
|
data = self._request_with_retry(
|
|
181
195
|
{
|
|
182
196
|
"model": self.model,
|
|
@@ -187,7 +201,8 @@ class AgnesChatAPI:
|
|
|
187
201
|
"temperature": 0.7,
|
|
188
202
|
"max_tokens": max_tokens,
|
|
189
203
|
},
|
|
190
|
-
timeout=
|
|
204
|
+
timeout=get_settings().agnes_chat_timeout,
|
|
205
|
+
timeout_retry_limit=timeout_retry_limit,
|
|
191
206
|
)
|
|
192
207
|
return data["choices"][0]["message"]["content"]
|
|
193
208
|
|
package/core/api/rate_limiter.py
CHANGED
|
@@ -272,6 +272,7 @@ def request_with_key_rotation(
|
|
|
272
272
|
max_retries: int = 3,
|
|
273
273
|
retry_base_delay: float = 20.0,
|
|
274
274
|
key_ring=None,
|
|
275
|
+
timeout_retry_limit: int | None = None,
|
|
275
276
|
**requester_kwargs,
|
|
276
277
|
):
|
|
277
278
|
"""429 换 Key 立即重试;全 Key 429 或 5xx/超时/连接错误才指数退避。
|
|
@@ -284,6 +285,11 @@ def request_with_key_rotation(
|
|
|
284
285
|
3. 所有 Key 均 429(rotation 计数达到 len(keys) × 退避上限)-> 指数退避
|
|
285
286
|
4. 5xx / 超时 / 连接错误 -> 同 Key 指数退避(保持现状)
|
|
286
287
|
|
|
288
|
+
超时/连接错误单独受 ``timeout_retry_limit`` 约束(stability_hardening Phase 1):
|
|
289
|
+
- 默认 ``None``:退回按 ``max_retries`` 上限,行为与旧版完全一致(仅新增分支,回归面为零);
|
|
290
|
+
- 显式非 None:超时类错误的退避重试次数不超过该值(5xx / 429 的重试预算不受影响),
|
|
291
|
+
供 chat() 这类「扩大成功窗口但压缩失败等待」的调用方收紧超时重试。
|
|
292
|
+
|
|
287
293
|
Args:
|
|
288
294
|
requester: 可调用 (url, headers, **kw) -> requests.Response。
|
|
289
295
|
endpoint: API 接口相对路径(如 "/chat/completions")。完整 URL 依当前 Key
|
|
@@ -291,6 +297,7 @@ def request_with_key_rotation(
|
|
|
291
297
|
max_retries: 退避重试上限。
|
|
292
298
|
retry_base_delay: 指数退避基数(秒),delay = 基数 × (retries + 1)。
|
|
293
299
|
key_ring: KeyRing 实例;None 时取全局单例。
|
|
300
|
+
timeout_retry_limit: 超时/连接类错误的最大重试次数;None = 沿用 max_retries。
|
|
294
301
|
**requester_kwargs: 透传给 requester(json/timeout 等,不含 headers——
|
|
295
302
|
headers 由本函数基于当前 Key 自动生成)。
|
|
296
303
|
|
|
@@ -307,6 +314,7 @@ def request_with_key_rotation(
|
|
|
307
314
|
ring = key_ring or get_key_ring()
|
|
308
315
|
base_headers = requester_kwargs.pop("headers", None) or {}
|
|
309
316
|
retries = 0
|
|
317
|
+
timeout_retries = 0
|
|
310
318
|
rotations = 0
|
|
311
319
|
max_rotations = len(ring) * max_retries
|
|
312
320
|
while True:
|
|
@@ -318,10 +326,15 @@ def request_with_key_rotation(
|
|
|
318
326
|
try:
|
|
319
327
|
resp = requester(url, headers=headers, **requester_kwargs)
|
|
320
328
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
321
|
-
if
|
|
322
|
-
|
|
323
|
-
|
|
329
|
+
timeout_limit = timeout_retry_limit if timeout_retry_limit is not None else max_retries
|
|
330
|
+
if timeout_retries < timeout_limit and retries < max_retries:
|
|
331
|
+
delay = retry_base_delay * (timeout_retries + 1)
|
|
332
|
+
logger.warning(
|
|
333
|
+
f"[KeyRotation] {type(e).__name__}, 退避 {delay}s 后重试 "
|
|
334
|
+
f"(timeout retry {timeout_retries + 1}/{timeout_limit})"
|
|
335
|
+
)
|
|
324
336
|
time.sleep(delay)
|
|
337
|
+
timeout_retries += 1
|
|
325
338
|
retries += 1
|
|
326
339
|
continue
|
|
327
340
|
raise
|
package/core/config.py
CHANGED
|
@@ -18,7 +18,7 @@ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
|
|
18
18
|
# ═══════════════════════════════════════════════════
|
|
19
19
|
# 应用版本号(v6.1 新增:发版时同步更新,见 docs/dev/release_process.md)
|
|
20
20
|
# ═══════════════════════════════════════════════════
|
|
21
|
-
APP_VERSION = "6.5.
|
|
21
|
+
APP_VERSION = "6.5.2"
|
|
22
22
|
|
|
23
23
|
# 未配置 API Key 时的统一报错文案(含免费获取与在线体验兜底,全站路由共用)
|
|
24
24
|
API_KEY_MISSING_MSG = (
|
|
@@ -201,6 +201,7 @@ try:
|
|
|
201
201
|
# ── 合成 / 字幕 ──
|
|
202
202
|
agnes_subtitle_ass: bool = True # 2.1c 字幕 ASS 单链灰度开关
|
|
203
203
|
agnes_video_poll_timeout: int = 1800 # 1.2 视频轮询总超时
|
|
204
|
+
agnes_chat_timeout: int = 300 # stability_hardening P1:chat 单次读超时(默认与 chat_multimodal 对齐)
|
|
204
205
|
|
|
205
206
|
# ── CORS(PR #33 吸收 Phase 2:可配置跨源白名单)──
|
|
206
207
|
# 供独立本地伴侣工具(如 agnes-simple-ui)从浏览器跨源调用本服务 API。
|
|
@@ -242,6 +243,7 @@ except ImportError: # pragma: no cover - pydantic-settings 为必备依赖,
|
|
|
242
243
|
"0", "false", "off",
|
|
243
244
|
)
|
|
244
245
|
self.agnes_video_poll_timeout = int(os.environ.get("AGNES_VIDEO_POLL_TIMEOUT", "1800"))
|
|
246
|
+
self.agnes_chat_timeout = int(os.environ.get("AGNES_CHAT_TIMEOUT", "300"))
|
|
245
247
|
self.agnes_cors_origins = os.environ.get("AGNES_CORS_ORIGINS", "")
|
|
246
248
|
_cors_enabled = os.environ.get("AGNES_CORS_ENABLED", "").strip().lower()
|
|
247
249
|
if _cors_enabled in ("0", "false", "off"):
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""core/gallery_cache.py — 产物画廊缩略图缓存(P1 v1.x,可选增强)。
|
|
2
|
+
|
|
3
|
+
缩略图放**独立缓存目录** ``.agnes_config/gallery_cache/``,**不在任务目录内,
|
|
4
|
+
不破坏任务目录结构**。惰性生成:首次访问时对未缓存的成片用 ffmpeg 抽单帧,
|
|
5
|
+
缓存后复用。该目录属可重建 Cache,而非任务产物;画廊仍**纯只读**,不触碰删除。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from core.compositor.ffmpeg_tool import resolve_binary
|
|
14
|
+
from core.config import CONFIG_DIR
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_THUMB_DIR = os.path.join(CONFIG_DIR, "gallery_cache")
|
|
19
|
+
_THUMB_EXT = ".jpg"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _thumb_path(task_id: str) -> str:
|
|
23
|
+
return os.path.join(_THUMB_DIR, task_id + _THUMB_EXT)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def has_thumb(task_id: str) -> bool:
|
|
27
|
+
return os.path.isfile(_thumb_path(task_id))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ensure_thumb(task_id: str, final_video_file: str) -> str | None:
|
|
31
|
+
"""确保指定视频成片的缩略图存在,缺失时惰性抽帧生成。
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
task_id: 任务 id(用作缓存文件名,天然唯一)。
|
|
35
|
+
final_video_file: 成片绝对路径。
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
缩略图路径;若成片不存在或抽帧失败返回 None(前端回退原生首帧)。
|
|
39
|
+
"""
|
|
40
|
+
out = _thumb_path(task_id)
|
|
41
|
+
if os.path.isfile(out):
|
|
42
|
+
return out
|
|
43
|
+
if not final_video_file or not os.path.isfile(final_video_file):
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
os.makedirs(_THUMB_DIR, exist_ok=True)
|
|
47
|
+
# 抽成片 1s 处一帧作封面,宽高限制到 480p 以控制体积
|
|
48
|
+
subprocess.run([
|
|
49
|
+
resolve_binary("ffmpeg"), "-y",
|
|
50
|
+
"-ss", "1",
|
|
51
|
+
"-i", final_video_file,
|
|
52
|
+
"-frames:v", "1",
|
|
53
|
+
"-vf", "scale=480:-2",
|
|
54
|
+
"-q:v", "4",
|
|
55
|
+
out,
|
|
56
|
+
], stdin=subprocess.DEVNULL, capture_output=True, check=True, timeout=30)
|
|
57
|
+
return out
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.warning("[GalleryCache] Failed to build thumbnail for %s: %s", task_id, e)
|
|
60
|
+
return None
|
|
@@ -9,7 +9,7 @@ from typing import Callable, Optional
|
|
|
9
9
|
from core.api.agnes_image import AgnesImageAPI
|
|
10
10
|
from core.api.agnes_video import AgnesVideoAPI
|
|
11
11
|
from core.config import DEFAULT_TEXT_MODEL
|
|
12
|
-
from core.pipelines import MultiScenePipeline
|
|
12
|
+
from core.pipelines import MultiScenePipeline, StepStatus
|
|
13
13
|
from core.screenwriter import Screenwriter
|
|
14
14
|
from models.task import CreativeVideoTask
|
|
15
15
|
|
|
@@ -209,6 +209,24 @@ class CreativeVideoPipeline(
|
|
|
209
209
|
)
|
|
210
210
|
self._check_shutdown()
|
|
211
211
|
|
|
212
|
+
# 回写 scenes 状态:通用实现(multi_scene)会在生成时同步
|
|
213
|
+
# scene.video_file / video_status,但链式路径(steps_video)从不回写,
|
|
214
|
+
# 导致 task_state.scenes 始终停在 pending 空值。这里按文件系统补一次
|
|
215
|
+
# 回填,使续传/查询看到真实生成结果。
|
|
216
|
+
scenes = getattr(self._state, "scenes", None)
|
|
217
|
+
if scenes:
|
|
218
|
+
remap = {}
|
|
219
|
+
for idx, path in enumerate(self._all_video_paths):
|
|
220
|
+
remap[idx] = path
|
|
221
|
+
for i, scene in enumerate(scenes):
|
|
222
|
+
if i in remap:
|
|
223
|
+
scene.video_file = remap[i]
|
|
224
|
+
scene.video_status = StepStatus.COMPLETED
|
|
225
|
+
scene.status = StepStatus.COMPLETED
|
|
226
|
+
self.task_manager.update_state(
|
|
227
|
+
scenes=[s.model_dump() for s in scenes],
|
|
228
|
+
)
|
|
229
|
+
|
|
212
230
|
# ------------------------------------------------------------------
|
|
213
231
|
# 音频生成(保留原逻辑)
|
|
214
232
|
# ------------------------------------------------------------------
|
|
@@ -20,7 +20,7 @@ from core.async_io import read_text
|
|
|
20
20
|
from core.audio.voices import duration_len, estimate_chars_per_sec
|
|
21
21
|
from core.compositor.concatenator import VideoConcatenator
|
|
22
22
|
from core.config import DEFAULT_TEXT_MODEL
|
|
23
|
-
from core.pipelines import MultiScenePipeline
|
|
23
|
+
from core.pipelines import CheckpointPause, MultiScenePipeline, PipelineShutdown
|
|
24
24
|
from core.screenwriter import Screenwriter, is_prompt_language_explicit
|
|
25
25
|
from models.task import (
|
|
26
26
|
ManuscriptParagraph,
|
|
@@ -309,19 +309,56 @@ class ManuscriptVideoPipeline(MultiScenePipeline):
|
|
|
309
309
|
para.text,
|
|
310
310
|
self._state.style,
|
|
311
311
|
)
|
|
312
|
-
|
|
312
|
+
# stability_hardening P2:返回值防御归一化为 str(可能 None / 非 str)
|
|
313
|
+
para.scene_prompt = (prompt or "").strip() if isinstance(prompt, str) else ""
|
|
314
|
+
|
|
315
|
+
# stability_hardening P2(M3):单段 LLM 失败不累及整任务(issue #35 结构性来源)。
|
|
316
|
+
# 区分控制流异常与业务失败:
|
|
317
|
+
# - 中止类(Cancelled / PipelineShutdown / CheckpointPause)→ 继续向上传播,不隔离;
|
|
318
|
+
# - 业务失败 → 失败段 scene_prompt 留空,交下游跳过 + 续传重试。
|
|
319
|
+
results = await asyncio.gather(*(_gen_one(p) for p in pending), return_exceptions=True)
|
|
320
|
+
failures: list[tuple[ManuscriptParagraph, BaseException]] = []
|
|
321
|
+
for para, res in zip(pending, results):
|
|
322
|
+
if isinstance(res, (asyncio.CancelledError, PipelineShutdown, CheckpointPause)):
|
|
323
|
+
raise res
|
|
324
|
+
if isinstance(res, BaseException):
|
|
325
|
+
para.scene_prompt = ""
|
|
326
|
+
failures.append((para, res))
|
|
327
|
+
|
|
328
|
+
if failures:
|
|
329
|
+
if len(failures) == len(pending):
|
|
330
|
+
first = failures[0]
|
|
331
|
+
# 全段失败:显式 raise(不静默产出空片),异常信息含首个失败段落 index 与原因
|
|
332
|
+
raise RuntimeError(
|
|
333
|
+
f"稿件场景描述生成全部失败 {len(pending)} 段,"
|
|
334
|
+
f"首个失败段落 index={first[0].index},原因: {str(first[1])[:200]}"
|
|
335
|
+
) from first[1]
|
|
336
|
+
# 部分失败:任务继续
|
|
337
|
+
msg = (
|
|
338
|
+
f"场景描述生成完成 ({len(pending) - len(failures)}/{len(pending)} 段)"
|
|
339
|
+
f",{len(failures)} 段失败可 resume 重试"
|
|
340
|
+
)
|
|
341
|
+
logger.warning(
|
|
342
|
+
"[Manuscript] scene_prompt: %d/%d 段生成失败,失败段落 index=%s,"
|
|
343
|
+
"首个错误摘要: %s",
|
|
344
|
+
len(failures), len(pending),
|
|
345
|
+
[p.index for p, _ in failures],
|
|
346
|
+
str(failures[0][1])[:200],
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
msg = f"场景描述生成完成 ({len(pending)} 段)"
|
|
313
350
|
|
|
314
|
-
await asyncio.gather(*[_gen_one(p) for p in pending])
|
|
315
351
|
await self._emit(
|
|
316
352
|
"scene_prompts", "completed",
|
|
317
|
-
|
|
353
|
+
msg,
|
|
318
354
|
_PROGRESS_SCENE_PROMPTS_START + _PROGRESS_SCENE_PROMPTS_SPAN,
|
|
319
355
|
)
|
|
320
356
|
for p in pending:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
357
|
+
if p.scene_prompt:
|
|
358
|
+
logger.info(
|
|
359
|
+
"[Manuscript] scene_prompt %d: %s...",
|
|
360
|
+
p.index, p.scene_prompt[:80],
|
|
361
|
+
)
|
|
325
362
|
|
|
326
363
|
self.task_manager.update_state(paragraphs=paragraphs)
|
|
327
364
|
self.save_prompts({
|
package/core/presets.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""core/presets.py — 风格/提示词预设库(P0-1)。
|
|
2
|
+
|
|
3
|
+
数据来源分两层:
|
|
4
|
+
- **系统预设**(只读):`resource/presets/styles.json`,只存 id + category + prompt;
|
|
5
|
+
名称/分类文案走前端 i18n(避免 JSON 内联 22 语言)。
|
|
6
|
+
- **用户预设**(可读写):持久化到 `.agnes_config/presets.json`,name 用户自填,
|
|
7
|
+
不参与翻译,category 固定为 ``custom``。
|
|
8
|
+
|
|
9
|
+
纯函数、无后端依赖;复用 ``core.config.CONFIG_DIR`` 与原子写约定,不引入数据库,
|
|
10
|
+
不新增任务内实体文件。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
from core.config import _PROJECT_ROOT, CONFIG_DIR
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# 系统预设源文件(随项目分发,只读)
|
|
24
|
+
_SYSTEM_PRESETS_PATH = os.path.join(_PROJECT_ROOT, "resource", "presets", "styles.json")
|
|
25
|
+
# 用户预设持久化文件(与 config.json 同级)
|
|
26
|
+
_USER_PRESETS_PATH = os.path.join(CONFIG_DIR, "presets.json")
|
|
27
|
+
|
|
28
|
+
# 用户自定义预设的固定分类
|
|
29
|
+
USER_CATEGORY = "custom"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_system_presets() -> list[dict]:
|
|
33
|
+
"""读取系统预设,失败(缺失/损坏)时返回空列表。
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
``[{"id": str, "category": str, "prompt": str}, ...]``
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
with open(_SYSTEM_PRESETS_PATH, "r", encoding="utf-8") as f:
|
|
40
|
+
data = json.load(f)
|
|
41
|
+
if not isinstance(data, list):
|
|
42
|
+
return []
|
|
43
|
+
out = []
|
|
44
|
+
for item in data:
|
|
45
|
+
if not isinstance(item, dict):
|
|
46
|
+
continue
|
|
47
|
+
preset = {
|
|
48
|
+
"id": str(item.get("id", "")),
|
|
49
|
+
"category": str(item.get("category", "")),
|
|
50
|
+
"prompt": str(item.get("prompt", "")),
|
|
51
|
+
}
|
|
52
|
+
if preset["id"]:
|
|
53
|
+
out.append(preset)
|
|
54
|
+
return out
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.warning("[Presets] Failed to load system presets: %s", e)
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_user_raw() -> list[dict]:
|
|
61
|
+
"""读取用户预设列表(未校验),损坏/缺失时返回 []。"""
|
|
62
|
+
if not os.path.exists(_USER_PRESETS_PATH):
|
|
63
|
+
return []
|
|
64
|
+
try:
|
|
65
|
+
with open(_USER_PRESETS_PATH, "r", encoding="utf-8") as f:
|
|
66
|
+
data = json.load(f)
|
|
67
|
+
return data if isinstance(data, list) else []
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.warning("[Presets] Failed to load user presets: %s", e)
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_user_presets() -> list[dict]:
|
|
74
|
+
"""读取用户预设(结构化过滤)。
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
``[{"id", "name", "category": "custom", "prompt", "created_at"}, ...]``
|
|
78
|
+
"""
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
"id": str(p.get("id", "")),
|
|
82
|
+
"name": str(p.get("name", "未命名")),
|
|
83
|
+
"category": USER_CATEGORY,
|
|
84
|
+
"prompt": str(p.get("prompt", "")),
|
|
85
|
+
"created_at": str(p.get("created_at", "")),
|
|
86
|
+
}
|
|
87
|
+
for p in _load_user_raw()
|
|
88
|
+
if isinstance(p, dict) and p.get("id")
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _save_user_raw(items: list[dict]):
|
|
93
|
+
"""原子写用户预设到 .agnes_config/presets.json。"""
|
|
94
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
95
|
+
tmp_path = _USER_PRESETS_PATH + ".tmp"
|
|
96
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
97
|
+
json.dump(items, f, indent=2, ensure_ascii=False)
|
|
98
|
+
os.replace(tmp_path, _USER_PRESETS_PATH)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_user_preset(name: str, prompt: str) -> dict:
|
|
102
|
+
"""新增一条用户预设。
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
name: 用户自填名称(可重复,原样保存)。
|
|
106
|
+
prompt: 预设 prompt 本体。
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
新建的预设 dict(含 id / created_at)。
|
|
110
|
+
"""
|
|
111
|
+
preset = {
|
|
112
|
+
"id": "u_" + uuid.uuid4().hex[:10],
|
|
113
|
+
"name": name.strip() or "未命名",
|
|
114
|
+
"category": USER_CATEGORY,
|
|
115
|
+
"prompt": prompt.strip(),
|
|
116
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
117
|
+
}
|
|
118
|
+
items = _load_user_raw()
|
|
119
|
+
items.append(preset)
|
|
120
|
+
_save_user_raw(items)
|
|
121
|
+
return preset
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def delete_user_preset(preset_id: str) -> bool:
|
|
125
|
+
"""删除一条用户预设。
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
preset_id: 用户预设 id。
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True 删除成功;False 未找到该用户预设。
|
|
132
|
+
"""
|
|
133
|
+
items = _load_user_raw()
|
|
134
|
+
kept = [p for p in items if str(p.get("id", "")) != preset_id]
|
|
135
|
+
if len(kept) == len(items):
|
|
136
|
+
return False
|
|
137
|
+
_save_user_raw(kept)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def is_system_preset(preset_id: str) -> bool:
|
|
142
|
+
"""判断 preset_id 是否为系统预设(系统预设只读,不可删)。"""
|
|
143
|
+
return any(p["id"] == preset_id for p in load_system_presets())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_system_preset_ids() -> set[str]:
|
|
147
|
+
"""返回系统预设 id 集合(供删除校验用)。"""
|
|
148
|
+
return {p["id"] for p in load_system_presets()}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def grouped_presets() -> tuple[list[dict], list[dict]]:
|
|
152
|
+
"""返回 (系统预设按分类分组, 用户预设平面列表)。
|
|
153
|
+
|
|
154
|
+
系统分组用于前端「按分类下拉」;用户预设作为独立 ``custom`` 分组追加。
|
|
155
|
+
此组合避免在本模块内重复实现分类聚合逻辑。
|
|
156
|
+
"""
|
|
157
|
+
system = load_system_presets()
|
|
158
|
+
user = load_user_presets()
|
|
159
|
+
return system, user
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-short-video",
|
|
3
|
-
"version": "6.5.
|
|
3
|
+
"version": "6.5.2",
|
|
4
4
|
"description": "Free AI short-video generator — run the full free-short-video service locally with one command. Text-to-video, image generation, Edge-TTS narration, subtitles, and compositing, all free. Official site: https://video.lichuanyang.top",
|
|
5
5
|
"bin": {
|
|
6
6
|
"free-short-video": "bin/cli.js",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "docu_real",
|
|
4
|
+
"category": "photographic",
|
|
5
|
+
"prompt": "documentary photography, natural available light, authentic candid framing, photojournalistic realism, subdued natural color grade, fine film grain"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"id": "golden_hour",
|
|
9
|
+
"category": "photographic",
|
|
10
|
+
"prompt": "golden hour photography, warm low-angle sunlight, long soft shadows, warm color temperature, subtle lens flare, realistic texture, nostalgic feel"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "studio_portrait",
|
|
14
|
+
"category": "photographic",
|
|
15
|
+
"prompt": "professional studio portrait, three-point soft lighting, clean gradient background, catchlight in eyes, sharp focus, 85mm shallow depth of field, smooth natural skin"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "blockbuster",
|
|
19
|
+
"category": "cinematic",
|
|
20
|
+
"prompt": "epic cinematic look, anamorphic lens flare, shallow depth of field, dramatic lighting, teal-and-orange color grading, high contrast, Hollywood blockbuster feel"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "film_noir",
|
|
24
|
+
"category": "cinematic",
|
|
25
|
+
"prompt": "film noir, high-contrast black and white, deep shadows, dramatic harsh lighting, venetian blind patterns, moody 1940s atmosphere, film grain"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "moody_drama",
|
|
29
|
+
"category": "cinematic",
|
|
30
|
+
"prompt": "moody dramatic cinematic, low-key lighting, volumetric atmosphere, desaturated muted palette, intimate film aesthetics, shallow depth of field"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "ink_wash",
|
|
34
|
+
"category": "oriental",
|
|
35
|
+
"prompt": "traditional Chinese ink wash painting, rice paper texture, flowing brushstrokes, sparse composition, muted ink tones, elegant minimalism"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "wuxia",
|
|
39
|
+
"category": "oriental",
|
|
40
|
+
"prompt": "Chinese sword-fantasy cinema style, dramatic rim lighting, flowing silk robes, ethereal mist, epic heroic composition, cinematic color grade"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "guofeng",
|
|
44
|
+
"category": "oriental",
|
|
45
|
+
"prompt": "refined Chinese classical art style, delicate intricate detail, elegant muted palette, balanced negative space, graceful cultural aesthetic"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "watercolor",
|
|
49
|
+
"category": "painting",
|
|
50
|
+
"prompt": "soft watercolor painting, delicate washes, visible paper grain, fluid color blending, dreamy atmospheric mood, painterly texture"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "oil_classic",
|
|
54
|
+
"category": "painting",
|
|
55
|
+
"prompt": "classic oil painting, rich impasto brushstrokes, warm museum lighting, textured canvas, fine art masterpiece quality"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "storybook",
|
|
59
|
+
"category": "painting",
|
|
60
|
+
"prompt": "children's storybook illustration, hand-painted gouache, soft friendly shapes, cheerful pastel colors, whimsical detail, matte finish"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "anime_vivid",
|
|
64
|
+
"category": "animation",
|
|
65
|
+
"prompt": "modern anime illustration, clean lineart, vibrant colors, cel shading, detailed expressive eyes, studio quality, high-key flat lighting, flat shading"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "anime_retro",
|
|
69
|
+
"category": "animation",
|
|
70
|
+
"prompt": "retro anime aesthetic, 1980s hand-painted gouache backgrounds, cel-shaded characters, matte finish, nostalgic warm palette, flat shading"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "pixel_art",
|
|
74
|
+
"category": "animation",
|
|
75
|
+
"prompt": "16-bit pixel art, retro game aesthetic, limited color palette, crisp blocky pixels, nostalgic mood, clean grid composition"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "cyberpunk",
|
|
79
|
+
"category": "fantasy",
|
|
80
|
+
"prompt": "neon cyberpunk, futuristic cityscape, holographic glow, wet reflective surfaces, high contrast, night scene, anamorphic lens flare"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "dreamscape",
|
|
84
|
+
"category": "fantasy",
|
|
85
|
+
"prompt": "surreal dreamlike scene, ethereal soft glow, soft focus, pastel tones, floating elements, otherworldly atmosphere"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"id": "epic_fantasy",
|
|
89
|
+
"category": "fantasy",
|
|
90
|
+
"prompt": "epic high-fantasy concept art, dramatic volumetric lighting, vast scale, rich detailed environment, cinematic color grade, awe-inspiring composition"
|
|
91
|
+
}
|
|
92
|
+
]
|
package/server.py
CHANGED
|
@@ -31,8 +31,10 @@ from web import app_state # noqa: F401 兼容 re-export:旧代码 from server
|
|
|
31
31
|
from web.app_state import init_runtime_state
|
|
32
32
|
from web.routes import (
|
|
33
33
|
config_routes,
|
|
34
|
+
gallery_routes,
|
|
34
35
|
health_routes,
|
|
35
36
|
image_routes,
|
|
37
|
+
preset_routes,
|
|
36
38
|
preview_routes,
|
|
37
39
|
task_creation_routes,
|
|
38
40
|
task_routes,
|
|
@@ -204,9 +206,11 @@ app.include_router(health_routes.router)
|
|
|
204
206
|
app.include_router(config_routes.router)
|
|
205
207
|
app.include_router(workspace_routes.router)
|
|
206
208
|
app.include_router(voice_routes.router)
|
|
209
|
+
app.include_router(preset_routes.router)
|
|
207
210
|
app.include_router(image_routes.router)
|
|
208
211
|
app.include_router(video_routes.router)
|
|
209
212
|
app.include_router(task_routes.router)
|
|
213
|
+
app.include_router(gallery_routes.router)
|
|
210
214
|
app.include_router(task_creation_routes.router)
|
|
211
215
|
app.include_router(preview_routes.router)
|
|
212
216
|
|