free-short-video 6.4.2 → 6.4.4

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 (76) hide show
  1. package/README.md +8 -9
  2. package/core/api/agnes_chat.py +4 -3
  3. package/core/api/agnes_image.py +9 -5
  4. package/core/api/agnes_models.py +15 -3
  5. package/core/api/agnes_video.py +19 -10
  6. package/core/api/rate_limiter.py +12 -14
  7. package/core/async_io.py +62 -0
  8. package/core/audio/tts.py +4 -2
  9. package/core/config.py +3 -3
  10. package/core/pipelines/__init__.py +6 -9
  11. package/core/pipelines/anchor_video.py +2 -2
  12. package/core/pipelines/creative/pipeline.py +2 -2
  13. package/core/pipelines/creative/steps_audio.py +4 -6
  14. package/core/pipelines/creative/steps_script.py +11 -20
  15. package/core/pipelines/manuscript_video.py +5 -6
  16. package/core/pipelines/poetry_video.py +4 -7
  17. package/core/pipelines/simple_video.py +2 -2
  18. package/core/screenwriter/__init__.py +1 -1
  19. package/package.json +1 -1
  20. package/requirements.txt +1 -0
  21. package/server.py +3 -1
  22. package/sonar-project.properties +41 -0
  23. package/static/assets/ar-CQYg2nGj.js +1 -0
  24. package/static/assets/bn-GRmExguM.js +1 -0
  25. package/static/assets/de-BC7M_3b2.js +1 -0
  26. package/static/assets/es-B4nBYimU.js +1 -0
  27. package/static/assets/fa-BmlqC2rs.js +1 -0
  28. package/static/assets/fr-pidgrwQZ.js +1 -0
  29. package/static/assets/hi-BLey3CdD.js +1 -0
  30. package/static/assets/id-qgAFE4x8.js +1 -0
  31. package/static/assets/index-N-jLGcpC.js +45 -0
  32. package/static/assets/it-AkzVx0c6.js +1 -0
  33. package/static/assets/ja-5UwAsDwf.js +1 -0
  34. package/static/assets/ko-DKS32eFu.js +1 -0
  35. package/static/assets/ms-DSRBo61e.js +1 -0
  36. package/static/assets/nl-D90WoBj4.js +1 -0
  37. package/static/assets/pt-BF-gpCqr.js +1 -0
  38. package/static/assets/ru-BY7yjapB.js +1 -0
  39. package/static/assets/th-D1q4ZJcr.js +1 -0
  40. package/static/assets/tl-COEGFxts.js +1 -0
  41. package/static/assets/tr-EAmZYFuY.js +1 -0
  42. package/static/assets/ur-DeZ35cwW.js +1 -0
  43. package/static/assets/vi-2944d5L1.js +1 -0
  44. package/static/index.html +1 -1
  45. package/web/app_state.py +3 -1
  46. package/web/deps.py +3 -1
  47. package/web/helpers.py +6 -3
  48. package/web/log_safe.py +45 -0
  49. package/web/routes/config_routes.py +51 -21
  50. package/web/routes/image_routes.py +14 -5
  51. package/web/routes/task_creation_routes.py +23 -10
  52. package/web/routes/task_routes.py +15 -8
  53. package/web/routes/utility_routes.py +2 -2
  54. package/web/routes/video_routes.py +16 -12
  55. package/web/routes/voice_routes.py +2 -1
  56. package/static/assets/ar-DD2gvMKw.js +0 -1
  57. package/static/assets/bn-DPcCDLpk.js +0 -1
  58. package/static/assets/de-DFxaRKtY.js +0 -1
  59. package/static/assets/es-DoMCctKF.js +0 -1
  60. package/static/assets/fa-BzEKx0PN.js +0 -1
  61. package/static/assets/fr-C0bWeTQ6.js +0 -1
  62. package/static/assets/hi-C_hrAd50.js +0 -1
  63. package/static/assets/id-BlECjGv5.js +0 -1
  64. package/static/assets/index-DD5NtUvj.js +0 -45
  65. package/static/assets/it-BCF04BbE.js +0 -1
  66. package/static/assets/ja--ICvVBr-.js +0 -1
  67. package/static/assets/ko-DAjiyyWl.js +0 -1
  68. package/static/assets/ms-DamV6gQc.js +0 -1
  69. package/static/assets/nl-BOMKQzmH.js +0 -1
  70. package/static/assets/pt-CC05NXdy.js +0 -1
  71. package/static/assets/ru-DPWcNyU1.js +0 -1
  72. package/static/assets/th-9kl-UM3z.js +0 -1
  73. package/static/assets/tl-4mRVYM2I.js +0 -1
  74. package/static/assets/tr-dfD9cPiH.js +0 -1
  75. package/static/assets/ur-L0V50o2j.js +0 -1
  76. package/static/assets/vi-ChKtUMhj.js +0 -1
package/README.md CHANGED
@@ -1,23 +1,20 @@
1
1
  ---
2
2
 
3
- # What's New in v6.4.2
3
+ # What's New in v6.4.4
4
4
 
5
5
  ## What's New
6
6
 
7
7
  ### Features & Improvements
8
8
 
9
- * **Per-Key domain binding** — each API Key saved in the projects config can now carry its own access domain (`com` / `cn` / `cn_bak`), so keys issued on the global site and keys issued on the domestic site can coexist and be routed to the correct endpoint automatically. Keys can from the environment still use the global domain.
9
+ * **Image model selection enabled** — you can now choose the image model used for reference images, end frames, and standalone image generation directly in the Settings panel, instead of it being fixed to the built-in default.
10
10
 
11
- * **One-click "Auto-detect domains" button** — in the API Key config panel, trigger a probe that tests each key across the candidate domains and fills in the matching domain automatically, instead of guessing it by hand.
11
+ * **Agnes Image 2.5 Flash added and set as default** — the latest generation image model `agnes-image-2.5-flash` is now the default for both text-to-image and image-to-image workflows; the older `agnes-image-2.1-flash` and `agnes-image-2.0-flash` remain available as selectable options.
12
12
 
13
- * **Restored China fallback domain** **`apihub.agnes-ai.cn`** — this endpoint accepts both domestic and global keys, giving users a migration path when their key domain changes. It is not listed in the official docs and may be removed later, so we recommend choosing the domain that matches your key.
13
+ * **LLM upgraded to Agnes 2.5 Flash** — the deprecated `agnes-2.0-flash` text model is removed from the selector and replaced by `agnes-2.5-flash` for story, script, and narration generation, following the official model deprecation notice.
14
14
 
15
- ### Bug Fixes
16
-
17
- * **Fixed multi-key authentication failures across domains** — after the Chinese domain migration, global-site keys could fail authentication against the strict domestic endpoint; domain is now resolved per key with the restored fallback, so mixed global/domestic keys authenticate successfully.
18
-
19
- * **Domain mismatch hints in the config UI** — keys without a bound domain now show a clear "domain not set" hint, prompting users to pick or auto-detect the matching domain on their next visit.
15
+ ---
20
16
 
17
+ This is a maintenance release focused on model flexibility: open image model selection, a newer default image model, and migration off the deprecated text model.
21
18
 
22
19
  ---
23
20
 
@@ -45,6 +42,8 @@ free-short-video
45
42
  [![Website](https://img.shields.io/badge/website-video.lichuanyang.top-8A2BE2)](https://video.lichuanyang.top)
46
43
  [![Docker Hub](https://img.shields.io/docker/pulls/lcy362/free-short-video?label=docker%20pulls)](https://hub.docker.com/r/lcy362/free-short-video)
47
44
  [![npm](https://img.shields.io/npm/v/free-short-video?label=npm)](https://www.npmjs.com/package/free-short-video)
45
+ [![Sonar Quality Gate](https://img.shields.io/sonar/quality_gate/lcy362_agnes-video-generator?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge)](https://sonarcloud.io/project/overview?id=lcy362_agnes-video-generator)
46
+ [![Sonar Coverage](https://img.shields.io/sonar/coverage/lcy362_agnes-video-generator?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge)](https://sonarcloud.io/project/overview?id=lcy362_agnes-video-generator)
48
47
 
49
48
  <p align="center">
50
49
  <img src="images/home.png" alt="Agnes Video Generator — Free AI Video Generator" width="720">
@@ -58,7 +58,7 @@ def strip_code_fence(text: str) -> str:
58
58
  class AgnesChatAPI:
59
59
  """Agnes LLM Chat API 封装(text + multimodal)。"""
60
60
 
61
- def __init__(self, api_key: str, model: str = "agnes-2.0-flash"):
61
+ def __init__(self, api_key: str, model: str = "agnes-2.5-flash"):
62
62
  self.api_key = api_key
63
63
  self.model = model
64
64
  # 基础 headers(不含 Authorization):每次请求前经 _auth_headers() 注入当前 Key
@@ -217,16 +217,17 @@ class AgnesChatAPI:
217
217
  # Step 1: 去围栏
218
218
  cleaned = strip_code_fence(content)
219
219
  # Step 2: 直接解析
220
+ # json.JSONDecodeError 是 ValueError 的子类,仅捕获 ValueError(S5713)
220
221
  try:
221
222
  return json.loads(cleaned)
222
- except (json.JSONDecodeError, ValueError):
223
+ except ValueError:
223
224
  pass
224
225
  # Step 3: 正则提取首个 {…} 块
225
226
  match = _JSON_BLOCK_RE.search(cleaned)
226
227
  if match:
227
228
  try:
228
229
  return json.loads(match.group())
229
- except (json.JSONDecodeError, ValueError):
230
+ except ValueError:
230
231
  pass
231
232
  # Step 3.5: json_repair 修复(可选依赖,修复 LLM 常见缺冒号/尾随逗号/单引号)
232
233
  if repair_json is not None:
@@ -52,7 +52,7 @@ class AgnesImageAPI:
52
52
  def __init__(
53
53
  self,
54
54
  api_key: str,
55
- model: str = "agnes-image-2.1-flash",
55
+ model: str = "agnes-image-2.5-flash",
56
56
  i2i_model: Optional[str] = None,
57
57
  ):
58
58
  """初始化图片 API。
@@ -60,13 +60,13 @@ class AgnesImageAPI:
60
60
  Args:
61
61
  api_key: Agnes API Key。
62
62
  model: t2i 默认模型。
63
- i2i_model: i2i 默认模型。默认与 ``model`` 相同(官方 agnes-image-2.1-flash
64
- 同时支持 t2i 与 i2i)。如需回退到 2.0,可通过环境变量
63
+ i2i_model: i2i 默认模型。默认与 ``model`` 相同(官方 agnes-image-2.5-flash
64
+ 同时支持 t2i 与 i2i)。如需回退到旧版,可通过环境变量
65
65
  ``AGNES_IMAGE_I2I_MODEL`` 或显式传参覆盖。
66
66
  """
67
67
  self.api_key = api_key
68
68
  self.model = model
69
- # i2i 默认与 t2i 同模型(官方 2.1 同时支持 t2i/i2i);环境变量可回退到 2.0。
69
+ # i2i 默认与 t2i 同模型(官方 2.5 同时支持 t2i/i2i);环境变量可回退到旧版。
70
70
  from core.config import get_settings
71
71
  env_i2i = get_settings().agnes_image_i2i_model
72
72
  self.i2i_model = i2i_model or env_i2i or model
@@ -148,7 +148,11 @@ class AgnesImageAPI:
148
148
  "image": resolved,
149
149
  }
150
150
 
151
- logger.info(f"[AgnesImage] Generating ({'i2i' if use_i2i else 't2i'}): {prompt[:80]}...")
151
+ # S5145:不在日志中记录用户 prompt 内容(可能含敏感信息),只记模式与长度
152
+ logger.info(
153
+ f"[AgnesImage] Generating ({'i2i' if use_i2i else 't2i'}): "
154
+ f"prompt {len(prompt)} chars"
155
+ )
152
156
 
153
157
  resp = None
154
158
  attempt = 0
@@ -22,17 +22,20 @@ REQUEST_TIMEOUT = 20
22
22
  # 分组失败时的兜底列表
23
23
  _FALLBACK = {
24
24
  "text": [DEFAULT_TEXT_MODEL],
25
- "image": [DEFAULT_IMAGE_MODEL],
25
+ "image": [DEFAULT_IMAGE_MODEL, "agnes-image-2.1-flash", "agnes-image-2.0-flash"],
26
26
  "video": [DEFAULT_VIDEO_MODEL],
27
27
  }
28
28
 
29
+ # 已废弃、应从可选列表中剔除的模型 ID(如 agnes-2.0-flash,官方已迁移至 agnes-2.5-flash)
30
+ _DEPRECATED_MODELS = {"agnes-2.0-flash"}
31
+
29
32
 
30
33
  def _classify(model_id: str) -> str:
31
34
  """根据模型 ID 前缀判断分组。
32
35
 
33
36
  - ``agnes-image*`` → image
34
37
  - ``agnes-video*`` → video
35
- - 其余(如 ``agnes-2.0-flash`` / ``agnes-2.5-flash``)→ text
38
+ - 其余(如 ``agnes-2.5-flash``)→ text
36
39
  """
37
40
  if model_id.startswith("agnes-image"):
38
41
  return "image"
@@ -68,13 +71,22 @@ def fetch_available_models(api_key: str) -> dict:
68
71
  grouped = {"text": [], "image": [], "video": []}
69
72
  for item in data.get("data", []):
70
73
  mid = item.get("id")
71
- if not mid:
74
+ if not mid or mid in _DEPRECATED_MODELS:
72
75
  continue
73
76
  grouped[_classify(mid)].append(mid)
74
77
  # 任一分类为空则补回默认,避免 UI 空下拉
75
78
  for k, default in _FALLBACK.items():
76
79
  if not grouped[k]:
77
80
  grouped[k] = list(default)
81
+ # 确保各分类的默认模型始终在可选列表内(如服务端未返回时也能选择)
82
+ _defaults = {
83
+ "text": DEFAULT_TEXT_MODEL,
84
+ "image": DEFAULT_IMAGE_MODEL,
85
+ "video": DEFAULT_VIDEO_MODEL,
86
+ }
87
+ for k, d in _defaults.items():
88
+ if d not in grouped[k]:
89
+ grouped[k].append(d)
78
90
  return grouped
79
91
  except Exception as e: # noqa: BLE001
80
92
  logger.warning("[AgnesModels] fetch failed (%s), using fallback", e)
@@ -71,6 +71,22 @@ def is_remote_video_failure(exc: BaseException) -> bool:
71
71
  return "Video generation failed:" in str(exc)
72
72
 
73
73
 
74
+ def _read_json_cache(path: str) -> dict:
75
+ """读取 JSON 缓存文件(线程池执行,避免阻塞事件循环,S7493)。"""
76
+ with open(path, "r", encoding="utf-8") as f:
77
+ return json.loads(f.read())
78
+
79
+
80
+ def _write_json_cache(path: str, data: dict) -> None:
81
+ """原子写入 JSON 缓存文件(先写临时文件再 replace,线程池执行)。"""
82
+ tmp_file = path + ".tmp"
83
+ with open(tmp_file, "w", encoding="utf-8") as f:
84
+ json.dump(data, f)
85
+ f.flush()
86
+ os.fsync(f.fileno())
87
+ os.replace(tmp_file, path)
88
+
89
+
74
90
  class VideoOutput:
75
91
  def __init__(self, fmt: str, ext: str, data: str):
76
92
  self.fmt = fmt
@@ -147,8 +163,7 @@ class AgnesVideoAPI:
147
163
  _URL_CACHE_MAX_AGE = 3600 # 1 小时
148
164
  if os.path.exists(url_file):
149
165
  try:
150
- with open(url_file, "r", encoding="utf-8") as f:
151
- cache_data = json.loads(f.read())
166
+ cache_data = await asyncio.to_thread(_read_json_cache, url_file)
152
167
  cached_url = cache_data.get("url", "")
153
168
  cached_ts = cache_data.get("ts", 0)
154
169
  age = time.time() - cached_ts
@@ -170,13 +185,7 @@ class AgnesVideoAPI:
170
185
  url = await self._upload_image_to_url(ref)
171
186
  if url:
172
187
  try:
173
- cache_data = {"url": url, "ts": time.time()}
174
- tmp_file = url_file + ".tmp"
175
- with open(tmp_file, "w", encoding="utf-8") as f:
176
- json.dump(cache_data, f)
177
- f.flush()
178
- os.fsync(f.fileno())
179
- os.replace(tmp_file, url_file)
188
+ await asyncio.to_thread(_write_json_cache, url_file, {"url": url, "ts": time.time()})
180
189
  except Exception as e:
181
190
  logger.debug(f"[AgnesVideo] Failed to cache URL: {e}")
182
191
  return url
@@ -196,7 +205,7 @@ class AgnesVideoAPI:
196
205
  try:
197
206
  b64_data = self._path_to_b64(image_path)
198
207
  payload = {
199
- "model": "agnes-image-2.1-flash",
208
+ "model": "agnes-image-2.5-flash",
200
209
  "prompt": "Keep the image exactly as it is",
201
210
  "n": 1,
202
211
  "size": "1024x1024",
@@ -187,21 +187,19 @@ class AgnesRateLimiter:
187
187
  等待在事件循环中执行(不再占线程池);可被任务取消打断,或经
188
188
  ``stop_event`` 提前放行(停止后调用方不会再发请求,无需令牌)。
189
189
  """
190
- while True:
191
- wait_time = self._try_acquire()
192
- if wait_time is None:
193
- return
194
- self._record_wait(wait_time)
195
- remaining = wait_time
196
- while remaining > 0:
197
- if stop_event is not None and stop_event.is_set():
198
- return
199
- await asyncio.sleep(min(0.1, remaining))
200
- remaining -= 0.1
201
- # 预支语义(与同步 acquire 一致):等待完成即视为已获取令牌。
202
- # 注意不能再调用 _try_acquire()——last_refill 已被推进到
203
- # now+wait_time,再次计算会因 elapsed≈0 而永远不足。
190
+ wait_time = self._try_acquire()
191
+ if wait_time is None:
204
192
  return
193
+ self._record_wait(wait_time)
194
+ remaining = wait_time
195
+ while remaining > 0:
196
+ if stop_event is not None and stop_event.is_set():
197
+ return
198
+ await asyncio.sleep(min(0.1, remaining))
199
+ remaining -= 0.1
200
+ # 预支语义(与同步 acquire 一致):等待完成即视为已获取令牌。
201
+ # 注意不能再调用 _try_acquire()——last_refill 已被推进到
202
+ # now+wait_time,再次计算会因 elapsed≈0 而永远不足。
205
203
 
206
204
  @property
207
205
  def stats(self) -> dict:
@@ -0,0 +1,62 @@
1
+ """异步文件读写辅助(Sonar ``python:S7493``)。
2
+
3
+ 背景
4
+ ----
5
+ 在 ``async def`` 中直接使用内置 ``open()`` 会阻塞事件循环(文件 IO 虽短,
6
+ 但在高并发/大文件场景下会拖慢整个服务)。Sonar 将此类调用判为 BUG。
7
+
8
+ 方案
9
+ ----
10
+ 统一走 AnyIO 的 ``open_file``(FastAPI/Starlette 的既有依赖,无需新增包):
11
+ 它把文件读写下沉到线程池,对外暴露与内置 ``open()`` 对齐的异步上下文管理器。
12
+
13
+ 用法
14
+ ----
15
+ 需要完整文件对象时(如逐块写入)::
16
+
17
+ async with await async_open(path, "wb") as f:
18
+ await f.write(chunk)
19
+
20
+ 只是整体读写时用便捷函数更省事::
21
+
22
+ content = await read_text(path)
23
+ await write_text(path, json.dumps(data, ensure_ascii=False))
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from anyio import open_file
28
+
29
+ __all__ = [
30
+ "async_open",
31
+ "read_bytes",
32
+ "read_text",
33
+ "write_bytes",
34
+ "write_text",
35
+ ]
36
+
37
+ # 语义等价于内置 open(),返回 AnyIO 异步文件对象(需 ``await`` 后再 ``async with``)
38
+ async_open = open_file
39
+
40
+
41
+ async def read_text(path: str, encoding: str = "utf-8") -> str:
42
+ """异步读取整个文本文件。"""
43
+ async with await async_open(path, "r", encoding=encoding) as f:
44
+ return await f.read()
45
+
46
+
47
+ async def write_text(path: str, data: str, encoding: str = "utf-8") -> None:
48
+ """异步写入整个文本文件(覆盖写)。"""
49
+ async with await async_open(path, "w", encoding=encoding) as f:
50
+ await f.write(data)
51
+
52
+
53
+ async def read_bytes(path: str) -> bytes:
54
+ """异步读取整个二进制文件。"""
55
+ async with await async_open(path, "rb") as f:
56
+ return await f.read()
57
+
58
+
59
+ async def write_bytes(path: str, data: bytes) -> None:
60
+ """异步写入整个二进制文件(覆盖写)。"""
61
+ async with await async_open(path, "wb") as f:
62
+ await f.write(data)
package/core/audio/tts.py CHANGED
@@ -11,6 +11,8 @@ from typing import Optional, Tuple
11
11
 
12
12
  import edge_tts
13
13
 
14
+ from core.async_io import async_open
15
+
14
16
  logger = logging.getLogger(__name__)
15
17
 
16
18
 
@@ -60,10 +62,10 @@ class EdgeTTSEngine(TTSEngine):
60
62
  sub_maker = edge_tts.SubMaker()
61
63
 
62
64
  tmp_path = output_path + ".tmp"
63
- with open(tmp_path, "wb") as audio_file:
65
+ async with await async_open(tmp_path, "wb") as audio_file:
64
66
  async for chunk in communicate.stream():
65
67
  if chunk["type"] == "audio":
66
- audio_file.write(chunk["data"])
68
+ await audio_file.write(chunk["data"])
67
69
  elif chunk["type"] in ("WordBoundary", "SentenceBoundary"):
68
70
  sub_maker.feed(chunk)
69
71
 
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.4.2"
21
+ APP_VERSION = "6.4.4"
22
22
 
23
23
  # 未配置 API Key 时的统一报错文案(含免费获取与在线体验兜底,全站路由共用)
24
24
  API_KEY_MISSING_MSG = (
@@ -870,8 +870,8 @@ DURATION_FRAME_MAP = {
870
870
  # ═══════════════════════════════════════════════════
871
871
 
872
872
  # 各类型 Agnes 模型默认值(与三个 API 客户端的默认 model 对齐)
873
- DEFAULT_TEXT_MODEL = "agnes-2.0-flash"
874
- DEFAULT_IMAGE_MODEL = "agnes-image-2.1-flash"
873
+ DEFAULT_TEXT_MODEL = "agnes-2.5-flash"
874
+ DEFAULT_IMAGE_MODEL = "agnes-image-2.5-flash"
875
875
  DEFAULT_VIDEO_MODEL = "agnes-video-v2.0"
876
876
 
877
877
  DEFAULT_MODELS = {
@@ -24,6 +24,7 @@ _ENCODING_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
24
24
  max_workers=2, thread_name_prefix="encoding"
25
25
  )
26
26
 
27
+ from core.async_io import read_text, write_text
27
28
  from core.compositor.watermark import add_watermark, detect_language
28
29
  from core.config import get_watermark_config
29
30
  from core.task_manager import TaskManager
@@ -542,8 +543,7 @@ class BasePipeline(ABC):
542
543
  )
543
544
 
544
545
  if srt_content.strip():
545
- with open(srt_path, "w", encoding="utf-8") as f:
546
- f.write(srt_content)
546
+ await write_text(srt_path, srt_content)
547
547
  entry_count = srt_content.count("\n\n") + 1 if "\n\n" in srt_content else 0
548
548
  logger.info(
549
549
  "[Subtitle] Scene-aware SRT: %d entries across %d segments",
@@ -557,19 +557,17 @@ class BasePipeline(ABC):
557
557
  total_dur = actual_audio_dur if actual_audio_dur > 0 else sum(segment_durations)
558
558
  SubtitleGenerator.text_to_srt(full_text, srt_path, total_dur)
559
559
  else:
560
- with open(srt_path, "w", encoding="utf-8") as f:
561
- f.write("")
560
+ await write_text(srt_path, "")
562
561
 
563
562
  # ── 2.5 统一后处理:确保每条字幕不超过 2 行(参考中文短字幕规范,适配所有语言)──
564
563
  try:
565
- raw_srt = open(srt_path, "r", encoding="utf-8").read()
564
+ raw_srt = await read_text(srt_path)
566
565
  fixed_srt = SubtitleGenerator.enforce_max_lines(
567
566
  raw_srt, max_lines=2,
568
567
  video_width=video_width, fontsize=subtitle_config.style.fontsize,
569
568
  )
570
569
  if fixed_srt and fixed_srt != raw_srt:
571
- with open(srt_path, "w", encoding="utf-8") as f:
572
- f.write(fixed_srt)
570
+ await write_text(srt_path, fixed_srt)
573
571
  entry_count = fixed_srt.count("\n\n") + 1 if "\n\n" in fixed_srt else 1
574
572
  logger.info(
575
573
  "[Subtitle] enforce_max_lines applied: ≤2 lines/entry (%d entries)",
@@ -593,8 +591,7 @@ class BasePipeline(ABC):
593
591
  style_hints=subtitle_config.style.style_hints,
594
592
  **({"role": role} if role else {}),
595
593
  )
596
- with open(sp, "w", encoding="utf-8") as f:
597
- json.dump(styles, f, ensure_ascii=False, indent=2)
594
+ await write_text(sp, json.dumps(styles, ensure_ascii=False, indent=2))
598
595
  styles_path = sp
599
596
  logger.info(
600
597
  "[Subtitle] LLM styles saved: %s (%d entries)",
@@ -75,8 +75,8 @@ class AnchorPipeline(MultiScenePipeline):
75
75
  api_key: str,
76
76
  task_id: str,
77
77
  dir_name: Optional[str] = None,
78
- chat_model: str = "agnes-2.0-flash",
79
- image_model: str = "agnes-image-2.1-flash",
78
+ chat_model: str = "agnes-2.5-flash",
79
+ image_model: str = "agnes-image-2.5-flash",
80
80
  video_model: str = "agnes-video-v2.0",
81
81
  progress_callback: Optional[Callable] = None,
82
82
  shutdown_event: Optional[asyncio.Event] = None,
@@ -47,8 +47,8 @@ class CreativeVideoPipeline(
47
47
  api_key: str,
48
48
  task_id: str,
49
49
  dir_name: Optional[str] = None,
50
- chat_model: str = "agnes-2.0-flash",
51
- image_model: str = "agnes-image-2.1-flash",
50
+ chat_model: str = "agnes-2.5-flash",
51
+ image_model: str = "agnes-image-2.5-flash",
52
52
  video_model: str = "agnes-video-v2.0",
53
53
  progress_callback: Optional[Callable] = None,
54
54
  shutdown_event: Optional[asyncio.Event] = None,
@@ -6,6 +6,7 @@ import os
6
6
  import re
7
7
  from typing import Optional
8
8
 
9
+ from core.async_io import read_text
9
10
  from core.audio.voices import estimate_chars_per_sec
10
11
  from core.compositor.concatenator import VideoConcatenator
11
12
  from core.screenwriter import clean_narration_text
@@ -222,8 +223,7 @@ class AudioStepsMixin:
222
223
  script_path = os.path.join(self.working_dir, "script.json")
223
224
  if os.path.exists(script_path):
224
225
  try:
225
- with open(script_path, "r", encoding="utf-8") as f:
226
- script_prompts = json.load(f)
226
+ script_prompts = json.loads(await read_text(script_path))
227
227
  except Exception:
228
228
  pass
229
229
  prompts_data = {
@@ -401,10 +401,8 @@ class AudioStepsMixin:
401
401
  prompts_path = os.path.join(self.working_dir, "prompts.json")
402
402
  existing = {}
403
403
  if os.path.exists(prompts_path):
404
- with open(prompts_path, "r", encoding="utf-8") as f:
405
- existing = json.load(f)
406
- with open(styles_path, "r", encoding="utf-8") as f:
407
- existing["subtitle_styles"] = json.load(f)
404
+ existing = json.loads(await read_text(prompts_path))
405
+ existing["subtitle_styles"] = json.loads(await read_text(styles_path))
408
406
  self.save_prompts(existing)
409
407
  except Exception:
410
408
  pass
@@ -5,6 +5,7 @@ import logging
5
5
  import os
6
6
  from typing import List
7
7
 
8
+ from core.async_io import read_text, write_text
8
9
  from core.pipelines import PipelineShutdown
9
10
  from models.task import SceneTask, StepStatus
10
11
 
@@ -55,8 +56,7 @@ class ScriptStepsMixin:
55
56
  if self._state.step_image_analysis == StepStatus.COMPLETED:
56
57
  analysis_file = self._state.image_analysis_file
57
58
  if os.path.exists(analysis_file):
58
- with open(analysis_file, "r", encoding="utf-8") as f:
59
- content = f.read()
59
+ content = await read_text(analysis_file)
60
60
  # 检测之前分析失败留下的错误文本,强制重新分析
61
61
  if "(分析失败" in content:
62
62
  logger.warning(
@@ -95,8 +95,7 @@ class ScriptStepsMixin:
95
95
  )
96
96
 
97
97
  analysis_file = os.path.join(self.working_dir, "image_analysis.txt")
98
- with open(analysis_file, "w", encoding="utf-8") as f:
99
- f.write(image_context)
98
+ await write_text(analysis_file, image_context)
100
99
 
101
100
  self._state.step_image_analysis = StepStatus.COMPLETED
102
101
  self._state.image_analysis_file = analysis_file
@@ -244,8 +243,7 @@ class ScriptStepsMixin:
244
243
  story_path = self._state.story_file
245
244
  if os.path.exists(story_path):
246
245
  logger.info("[Pipeline] Step story: SKIP (already completed, file exists)")
247
- with open(story_path, "r", encoding="utf-8") as f:
248
- return f.read()
246
+ return await read_text(story_path)
249
247
  logger.warning("[Pipeline] Step story: marked completed but file missing, re-running")
250
248
 
251
249
  logger.info("[Pipeline] Step story: RUNNING")
@@ -261,8 +259,7 @@ class ScriptStepsMixin:
261
259
  )
262
260
 
263
261
  story_path = os.path.join(self.working_dir, "story.txt")
264
- with open(story_path, "w", encoding="utf-8") as f:
265
- f.write(story)
262
+ await write_text(story_path, story)
266
263
 
267
264
  self._state.step_story = StepStatus.COMPLETED
268
265
  self._state.story_file = story_path
@@ -314,8 +311,7 @@ class ScriptStepsMixin:
314
311
  if os.path.exists(ref_img_path) and os.path.exists(ref_prompt_path):
315
312
  self._state.step_character_ref = StepStatus.COMPLETED
316
313
  self._state.character_ref_file = ref_img_path
317
- with open(ref_prompt_path, "r", encoding="utf-8") as f:
318
- self._state.character_ref_prompt = f.read()
314
+ self._state.character_ref_prompt = await read_text(ref_prompt_path)
319
315
  self.task_manager.update_state(
320
316
  step_character_ref=StepStatus.COMPLETED,
321
317
  character_ref_file=ref_img_path,
@@ -327,8 +323,7 @@ class ScriptStepsMixin:
327
323
  char_prompt = await asyncio.to_thread(
328
324
  self.screenwriter.extract_character_description, story, self._state.style
329
325
  )
330
- with open(ref_prompt_path, "w", encoding="utf-8") as f:
331
- f.write(char_prompt)
326
+ await write_text(ref_prompt_path, char_prompt)
332
327
 
333
328
  await self._emit("character_ref", "running", "正在生成角色参考图 (t2i)...", _PROGRESS_CHARACTER_REF_T2I)
334
329
  img_output = await self.image_generator.generate_single_image(
@@ -365,8 +360,7 @@ class ScriptStepsMixin:
365
360
  script_path = self._state.script_file
366
361
  if os.path.exists(script_path):
367
362
  logger.info("[Pipeline] Step script: SKIP (already completed, file exists)")
368
- with open(script_path, "r", encoding="utf-8") as f:
369
- scenes = json.load(f)
363
+ scenes = json.loads(await read_text(script_path))
370
364
  if self._state.scene_count == len(scenes):
371
365
  return scenes
372
366
  logger.warning("[Pipeline] Step script: scene count mismatch, re-running")
@@ -383,8 +377,7 @@ class ScriptStepsMixin:
383
377
  )
384
378
 
385
379
  script_path = os.path.join(self.working_dir, "script.json")
386
- with open(script_path, "w", encoding="utf-8") as f:
387
- json.dump(scenes, f, ensure_ascii=False, indent=2)
380
+ await write_text(script_path, json.dumps(scenes, ensure_ascii=False, indent=2))
388
381
 
389
382
  self._state.scene_count = len(scenes)
390
383
  # Map durations to scenes: use scene_durations list, pad/trim as needed
@@ -453,8 +446,7 @@ class ScriptStepsMixin:
453
446
  prompts_path = self._state.end_frame_prompts_file
454
447
  if os.path.exists(prompts_path):
455
448
  logger.info("[Pipeline] Step end_frame_prompts: SKIP (already completed, file exists)")
456
- with open(prompts_path, "r", encoding="utf-8") as f:
457
- return json.load(f)
449
+ return json.loads(await read_text(prompts_path))
458
450
  logger.warning("[Pipeline] Step end_frame_prompts: marked completed but file missing, re-running")
459
451
 
460
452
  logger.info("[Pipeline] Step end_frame_prompts: RUNNING")
@@ -471,8 +463,7 @@ class ScriptStepsMixin:
471
463
  )
472
464
 
473
465
  prompts_path = os.path.join(self.working_dir, "end_frame_prompts.json")
474
- with open(prompts_path, "w", encoding="utf-8") as f:
475
- json.dump(end_frame_prompts, f, ensure_ascii=False, indent=2)
466
+ await write_text(prompts_path, json.dumps(end_frame_prompts, ensure_ascii=False, indent=2))
476
467
 
477
468
  self._state.step_end_frame_prompts = StepStatus.COMPLETED
478
469
  self._state.end_frame_prompts_file = prompts_path
@@ -16,6 +16,7 @@ import re
16
16
  from typing import Callable, List, Optional
17
17
 
18
18
  from core.api.agnes_video import AgnesVideoAPI, VideoTaskCancelled
19
+ from core.async_io import read_text
19
20
  from core.audio.voices import duration_len, estimate_chars_per_sec
20
21
  from core.compositor.concatenator import VideoConcatenator
21
22
  from core.pipelines import MultiScenePipeline
@@ -143,8 +144,8 @@ class ManuscriptVideoPipeline(MultiScenePipeline):
143
144
  api_key: str,
144
145
  task_id: str,
145
146
  dir_name: str = None,
146
- chat_model: str = "agnes-2.0-flash",
147
- image_model: str = "agnes-image-2.1-flash",
147
+ chat_model: str = "agnes-2.5-flash",
148
+ image_model: str = "agnes-image-2.5-flash",
148
149
  video_model: str = "agnes-video-v2.0",
149
150
  progress_callback: Optional[Callable] = None,
150
151
  shutdown_event: Optional[asyncio.Event] = None,
@@ -542,10 +543,8 @@ class ManuscriptVideoPipeline(MultiScenePipeline):
542
543
  prompts_path = os.path.join(self.working_dir, "prompts.json")
543
544
  existing = {}
544
545
  if os.path.exists(prompts_path):
545
- with open(prompts_path, "r", encoding="utf-8") as f:
546
- existing = json.load(f)
547
- with open(styles_path, "r", encoding="utf-8") as f:
548
- existing["subtitle_styles"] = json.load(f)
546
+ existing = json.loads(await read_text(prompts_path))
547
+ existing["subtitle_styles"] = json.loads(await read_text(styles_path))
549
548
  self.save_prompts(existing)
550
549
  except Exception:
551
550
  pass
@@ -47,9 +47,6 @@ _PROGRESS_AUDIO_SCENE = 0.75
47
47
  _PROGRESS_SUBTITLE_SCENE = 0.87
48
48
  _PROGRESS_COMPOSITE_SCENE = 0.90
49
49
 
50
- _SCENE_LABEL_RE = re.compile(r"^(场景|Scene)\s*\d+|[((]\s*\d+\s*:\s*\d+")
51
-
52
-
53
50
  def _is_scene_label(line: str) -> bool:
54
51
  """判断一行是否为纯场景标签(如「场景 1(00:00 - 00:10)」),而非分镜描述。
55
52
 
@@ -76,7 +73,7 @@ class PoetryVideoPipeline(MultiScenePipeline):
76
73
  api_key: str,
77
74
  task_id: str,
78
75
  dir_name: Optional[str] = None,
79
- chat_model: str = "agnes-2.0-flash",
76
+ chat_model: str = "agnes-2.5-flash",
80
77
  video_model: str = "agnes-video-v2.0",
81
78
  progress_callback: Optional[callable] = None,
82
79
  shutdown_event: Optional = None,
@@ -424,9 +421,9 @@ class PoetryVideoPipeline(MultiScenePipeline):
424
421
  f"生成字幕 {idx+1}/{len(scenes)}...", _PROGRESS_SUBTITLE_SCENE,
425
422
  )
426
423
  # v2.0:优先用该场景 cues 生成精确字幕;cues 缺失则回退纯文本估算
427
- sub_maker = getattr(self, "_scene_sub_makers", {}).get(idx)
428
- if sub_maker is not None and getattr(sub_maker, "cues", None):
429
- SubtitleGenerator.cues_to_srt(sub_maker, srt_path)
424
+ scene_sub_maker = getattr(self, "_scene_sub_makers", {}).get(idx)
425
+ if scene_sub_maker is not None and getattr(scene_sub_maker, "cues", None):
426
+ SubtitleGenerator.cues_to_srt(scene_sub_maker, srt_path)
430
427
  if not (os.path.exists(srt_path) and os.path.getsize(srt_path) > 0):
431
428
  SubtitleGenerator.text_to_srt(
432
429
  text, srt_path, duration_sec=dur, chars_per_sec=_CHARS_PER_SEC,
@@ -37,8 +37,8 @@ class SimpleVideoPipeline(BasePipeline):
37
37
  api_key: str,
38
38
  task_id: str,
39
39
  dir_name: str = None,
40
- chat_model: str = "agnes-2.0-flash",
41
- image_model: str = "agnes-image-2.1-flash",
40
+ chat_model: str = "agnes-2.5-flash",
41
+ image_model: str = "agnes-image-2.5-flash",
42
42
  video_model: str = "agnes-video-v2.0",
43
43
  progress_callback: Optional[Callable] = None,
44
44
  shutdown_event: Optional[asyncio.Event] = None,