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.
Files changed (61) hide show
  1. package/.env.example +4 -0
  2. package/README.md +7 -5
  3. package/core/api/agnes_chat.py +18 -3
  4. package/core/api/rate_limiter.py +16 -3
  5. package/core/config.py +3 -1
  6. package/core/gallery_cache.py +60 -0
  7. package/core/pipelines/creative/pipeline.py +19 -1
  8. package/core/pipelines/manuscript_video.py +45 -8
  9. package/core/presets.py +159 -0
  10. package/package.json +1 -1
  11. package/resource/presets/styles.json +92 -0
  12. package/server.py +4 -0
  13. package/start.bat +20 -8
  14. package/static/assets/ar-CvuF8gFF.js +1 -0
  15. package/static/assets/bn-CWLD7QCi.js +1 -0
  16. package/static/assets/de-DOVh35jv.js +1 -0
  17. package/static/assets/es-CLzRtmI7.js +1 -0
  18. package/static/assets/fa-DNTSi8NJ.js +1 -0
  19. package/static/assets/fr-BpUCsjaC.js +1 -0
  20. package/static/assets/hi-Bkg-ypdW.js +1 -0
  21. package/static/assets/id-CJjRdrXY.js +1 -0
  22. package/static/assets/index-CgXb4MrZ.css +1 -0
  23. package/static/assets/index-Dn7-Q6vG.js +45 -0
  24. package/static/assets/it-CyVEADj1.js +1 -0
  25. package/static/assets/ja-yWWPg0BT.js +1 -0
  26. package/static/assets/ko-BbbGMI2i.js +1 -0
  27. package/static/assets/ms-CJ1UCmU_.js +1 -0
  28. package/static/assets/nl-7OqowihO.js +1 -0
  29. package/static/assets/pt-C8Z7vWM8.js +1 -0
  30. package/static/assets/ru-BJpeOLhl.js +1 -0
  31. package/static/assets/th-BOYCdRJN.js +1 -0
  32. package/static/assets/tl-DzOYHZWC.js +1 -0
  33. package/static/assets/tr-IYge5J2a.js +1 -0
  34. package/static/assets/ur-C24aPBX9.js +1 -0
  35. package/static/assets/vi-BfnnHjVU.js +1 -0
  36. package/static/index.html +2 -2
  37. package/web/routes/gallery_routes.py +167 -0
  38. package/web/routes/preset_routes.py +65 -0
  39. package/web/routes/video_routes.py +7 -2
  40. package/static/assets/ar-KHCrxRA_.js +0 -1
  41. package/static/assets/bn-Cdg9UtGd.js +0 -1
  42. package/static/assets/de-DpVpIIzl.js +0 -1
  43. package/static/assets/es-CbqgNYrf.js +0 -1
  44. package/static/assets/fa-eXTzpP2j.js +0 -1
  45. package/static/assets/fr-SR1IWdla.js +0 -1
  46. package/static/assets/hi-DrHcqlna.js +0 -1
  47. package/static/assets/id-DkV63qNl.js +0 -1
  48. package/static/assets/index-CGsvTiYV.css +0 -1
  49. package/static/assets/index-CL0ZNqqM.js +0 -45
  50. package/static/assets/it-Tos-zjmh.js +0 -1
  51. package/static/assets/ja-CbiPQ4QC.js +0 -1
  52. package/static/assets/ko-BO0zBA93.js +0 -1
  53. package/static/assets/ms-DnDGKrps.js +0 -1
  54. package/static/assets/nl-DaVWNF5X.js +0 -1
  55. package/static/assets/pt-BDbdLcbe.js +0 -1
  56. package/static/assets/ru-CuVlJkcC.js +0 -1
  57. package/static/assets/th-B_U6YxGQ.js +0 -1
  58. package/static/assets/tl-Dzn1t6Fh.js +0 -1
  59. package/static/assets/tr-DBSQCQnY.js +0 -1
  60. package/static/assets/ur-BpwQWAf9.js +0 -1
  61. 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.0
3
+ # What's New in v6.5.2
4
4
 
5
5
  ## What's New
6
6
 
7
7
  ### Features & Improvements
8
8
 
9
- * **Simple mode ("一键创作") — one idea to a finished creative video.** A new top-level tab in the main UI offers a three-step wizard: type an idea, generate a previewed storyboard with per-scene prompts and narration, then click once to submit a creative-video task with an automatic voice, subtitles and resolution preset. Because the storyboard is confirmed from the `preview-script` result before generation, wrong ideas are caught with a few cheap LLM calls instead of a full re-run of the video pipeline.
10
- * **In-place "AI 帮我改" (channel 1) editing.** On a checkpoint, you can now ask the AI to modify an artifact directly: JSON/TXT/SRT scripts are rewritten while keeping their structure, and reference images are edited and re-generated with a derived image-to-image prompt. A diff preview shows what will change before you apply and re-run the downstream pipeline.
11
- * **Discoverable, translated entry point.** The simple-mode tab is labelled "⚡ 一键创作" with a compact "新/NEW" badge so new users notice the quick path, and its labels are now natively translated across all 22 locales instead of falling back to a Chinese placeholder.
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
- * Completed the translation of the simple-mode tab label and its badge in the 20 non-zh/en locales, which previously displayed a Chinese placeholder.
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
 
@@ -109,7 +109,10 @@ class AgnesChatAPI:
109
109
  return content
110
110
  return ""
111
111
 
112
- def _request_with_retry(self, payload: dict, timeout: int = 120) -> dict:
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=120,
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
 
@@ -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 retries < max_retries:
322
- delay = retry_base_delay * (retries + 1)
323
- logger.warning(f"[KeyRotation] {type(e).__name__}, 退避 {delay}s 后重试")
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.0"
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
- para.scene_prompt = prompt.strip()
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
- f"场景描述生成完成 ({len(pending)} 段)",
353
+ msg,
318
354
  _PROGRESS_SCENE_PROMPTS_START + _PROGRESS_SCENE_PROMPTS_SPAN,
319
355
  )
320
356
  for p in pending:
321
- logger.info(
322
- "[Manuscript] scene_prompt %d: %s...",
323
- p.index, p.scene_prompt[:80],
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({
@@ -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.0",
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