free-short-video 7.0.4 → 7.0.6

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 (50) hide show
  1. package/README.md +4 -9
  2. package/core/audio/tts.py +9 -1
  3. package/core/compositor/ffmpeg_tool.py +181 -1
  4. package/core/compositor/watermark.py +3 -14
  5. package/core/config.py +1 -1
  6. package/core/i18n_backend.py +15 -0
  7. package/core/pipelines/__init__.py +13 -13
  8. package/core/pipelines/creative/steps_frames.py +2 -0
  9. package/core/pipelines/poetry_video.py +15 -32
  10. package/models/task.py +3 -3
  11. package/package.json +1 -1
  12. package/static/assets/{ar-BGwnHyw2.js → ar-DDU7VnXA.js} +1 -1
  13. package/static/assets/{bn-BZ1SVmZv.js → bn-nY2VgxNf.js} +1 -1
  14. package/static/assets/de-t0gEvqFh.js +1 -0
  15. package/static/assets/es-BrDX0bsl.js +1 -0
  16. package/static/assets/{fa-DTgnsV-R.js → fa-DC45iYzo.js} +1 -1
  17. package/static/assets/fr-jNdU3xMA.js +1 -0
  18. package/static/assets/{hi-CiRTbxUR.js → hi-DU2jFclD.js} +1 -1
  19. package/static/assets/id-xVm_LLjT.js +1 -0
  20. package/static/assets/index-B_DQr-bu.css +1 -0
  21. package/static/assets/index-D06v2INE.js +45 -0
  22. package/static/assets/it-CNEcqCqv.js +1 -0
  23. package/static/assets/ja-BMrQ7ES8.js +1 -0
  24. package/static/assets/ko-DLcdCpqD.js +1 -0
  25. package/static/assets/ms-lz3IP0pE.js +1 -0
  26. package/static/assets/nl-JdaJ6OFu.js +1 -0
  27. package/static/assets/pt-BAJ6NELp.js +1 -0
  28. package/static/assets/{ru-CYIQhw4j.js → ru-DUk7xiI4.js} +1 -1
  29. package/static/assets/{th-BHjOht4_.js → th-DBGXh4cH.js} +1 -1
  30. package/static/assets/tl-cWM0TcNr.js +1 -0
  31. package/static/assets/tr-DY7yRLf-.js +1 -0
  32. package/static/assets/{ur-CNslvoMb.js → ur-CBrqZI5F.js} +1 -1
  33. package/static/assets/vi-BWMCwlNI.js +1 -0
  34. package/static/index.html +2 -2
  35. package/web/routes/task_creation_routes.py +4 -4
  36. package/static/assets/de-CUKZPeV-.js +0 -1
  37. package/static/assets/es-DKx-teoF.js +0 -1
  38. package/static/assets/fr-QDOIUFKu.js +0 -1
  39. package/static/assets/id-DVJD5Y5W.js +0 -1
  40. package/static/assets/index-D9sLMHUd.css +0 -1
  41. package/static/assets/index-KzCkI4Os.js +0 -45
  42. package/static/assets/it-BsQ3SshU.js +0 -1
  43. package/static/assets/ja-D9O41FnB.js +0 -1
  44. package/static/assets/ko-BH0PhAjf.js +0 -1
  45. package/static/assets/ms-qcRbwWeG.js +0 -1
  46. package/static/assets/nl-BuXaX-G6.js +0 -1
  47. package/static/assets/pt-CC3c_p8Q.js +0 -1
  48. package/static/assets/tl-CLPCfL8g.js +0 -1
  49. package/static/assets/tr-_PXfKikt.js +0 -1
  50. package/static/assets/vi-BCoL1tPB.js +0 -1
package/README.md CHANGED
@@ -1,25 +1,20 @@
1
1
  ---
2
2
 
3
- # What's New in v7.0.4
3
+ # What's New in v7.0.6
4
4
 
5
5
  ## What's New
6
6
 
7
7
  ### Features & Improvements
8
8
 
9
- * **Upstream API error trends.** Errors that carry an HTTP status code are now aggregated per task — by status code, model type (chat / image / video) and API method — and returned by the task detail API. The frontend reports them anonymously as `api_error` events, and task-failure / creation-failure events now carry the extracted status code as well, so recurring upstream failures (for example `503 video_queue_full`) can be tracked as a trend over time instead of being seen as unrelated single errors. The aggregation lives in memory for the lifetime of the task and is cleared with it; `error_logs/` remains the offline source of truth.
10
- * **Honest "not ready yet" state while polling.** A transient `404` while waiting for a submitted video is now an expected intermediate state rather than an error. It no longer consumes the consecutive-failure budget and no longer gets flushed into `error_logs`, so the failure panel and the logs stop reporting failures for tasks that were in fact still queued upstream.
11
-
12
- ### Refactoring & Optimizations
13
-
14
- * Regression coverage for the upstream error-handling path: consecutive `404`s followed by a successful completion, and a permanent `404` that correctly falls back to the overall polling timeout.
9
+ * **Style preset picker shows what is active.** The preset dropdown was rebuilt as a native-select-style control: the trigger displays the currently selected preset name, the list marks the active entry with a checkmark, and a one-line hint under the field explains what picking a preset does. The trigger label, placeholder and hint are localized in all 22 interface languages.
15
10
 
16
11
  ### Bug Fixes
17
12
 
18
- * Fixed video tasks failing with a spurious error when the upstream gateway had not made the task record visible yet — a state observed for 420s and even 1100s+ under peak load. The overall polling timeout stays as the final backstop, and it keeps the `video_id` so the task can be resumed rather than reported as a server-confirmed failure.
13
+ * **No more Chinese visual style inside non-Chinese tasks.** The hardcoded Chinese style default was removed from the Creative and Poetry task forms, from the shared legacy task-creation endpoint, and from the Creative and Poetry task models. Leaving the style field empty now pre-fills it on page load with the language-neutral default preset (an English cinematic prompt), so the task, the generated scene prompts and the diagnostic report all stay in the language the user is working in.
19
14
 
20
15
  ---
21
16
 
22
- No action is required after upgrading. If a task still fails, the failure panel now shows the real upstream reason with its HTTP status code, and repeated upstream errors of the same kind become visible as a trend.
17
+ Existing projects do not need to be recreated. Reopening a Creative or Poetry form after upgrading shows the preset name in the style field, and you can pick any other preset — or clear the field — as before.
23
18
 
24
19
  ---
25
20
 
package/core/audio/tts.py CHANGED
@@ -12,6 +12,8 @@ from typing import Optional, Tuple
12
12
  import edge_tts
13
13
 
14
14
  from core.async_io import async_open
15
+ from core.compositor.ffmpeg_tool import resolve_binary
16
+ from core.i18n_backend import translate
15
17
 
16
18
  logger = logging.getLogger(__name__)
17
19
 
@@ -171,9 +173,15 @@ class SilentTTSEngine(TTSEngine):
171
173
 
172
174
  os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
173
175
 
176
+ # 统一解析 ffmpeg 路径(Issue #78):裸 "ffmpeg" 在无系统 ffmpeg 的
177
+ # Windows 上会让 CreateProcess 抛 [WinError 2],且错误信息完全不可读
178
+ ffmpeg = resolve_binary("ffmpeg")
179
+ if not ffmpeg:
180
+ raise RuntimeError(translate("error.ffmpeg_missing"))
181
+
174
182
  # 使用 ffmpeg 生成静音音频
175
183
  proc = await asyncio.create_subprocess_exec(
176
- "ffmpeg", "-y",
184
+ ffmpeg, "-y",
177
185
  "-f", "lavfi",
178
186
  "-i", "anullsrc=r=44100:cl=mono",
179
187
  "-t", str(duration_sec),
@@ -15,7 +15,9 @@
15
15
  """
16
16
  import logging
17
17
  import os
18
+ import re
18
19
  import shutil
20
+ import subprocess
19
21
 
20
22
  logger = logging.getLogger(__name__)
21
23
 
@@ -52,6 +54,153 @@ def resolve_ffprobe() -> "str | None":
52
54
  return resolve_binary("ffprobe")
53
55
 
54
56
 
57
+ def probe_duration(path: str, default: float = 0.0) -> float:
58
+ """探测媒体时长(秒),三级兜底,**永不抛异常**。
59
+
60
+ 背景(Issue #78):此前多处探测用裸 ``["ffprobe", ...]`` 直接交给
61
+ ``subprocess.run``,在只带 imageio-ffmpeg 内置二进制(无 ffprobe)的环境里
62
+ 要么抛异常被静默吞掉、要么把 ``None`` 塞进命令行,最终统一降级为 0.0/None,
63
+ 连「ffmpeg 可用时的兜底探测」也没有。此处按可用性依次尝试:
64
+
65
+ 1. ``ffprobe``(解析到真实路径时才用);
66
+ 2. ``ffmpeg -i`` 的 stderr ``Duration:`` 行(ffmpeg 为硬依赖,必有兜底);
67
+ 3. 调用方给定的 ``default``。
68
+
69
+ Args:
70
+ path: 媒体文件路径。
71
+ default: 全部探测失败时返回的兜底值(秒)。
72
+
73
+ Returns:
74
+ 时长(秒);无法探测时返回 ``default``。
75
+ """
76
+ if not path or not os.path.exists(path):
77
+ return default
78
+
79
+ ffprobe = resolve_binary("ffprobe")
80
+ if ffprobe:
81
+ try:
82
+ r = subprocess.run(
83
+ [ffprobe, "-v", "error", "-show_entries", "format=duration",
84
+ "-of", "default=noprint_wrappers=1:nokey=1", path],
85
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15,
86
+ )
87
+ val = float((r.stdout or "").strip() or 0)
88
+ if val > 0:
89
+ return val
90
+ except Exception as e:
91
+ logger.warning(f"[Compositor] ffprobe duration failed: {e}")
92
+
93
+ ffmpeg = resolve_binary("ffmpeg")
94
+ if ffmpeg:
95
+ try:
96
+ r = subprocess.run(
97
+ [ffmpeg, "-hide_banner", "-i", path],
98
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15,
99
+ )
100
+ m = re.search(
101
+ r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", r.stderr or ""
102
+ )
103
+ if m:
104
+ h, mi, s = m.groups()
105
+ val = int(h) * 3600 + int(mi) * 60 + float(s)
106
+ if val > 0:
107
+ logger.info(
108
+ f"[Compositor] duration via ffmpeg fallback: {val:.2f}s ({path})"
109
+ )
110
+ return val
111
+ except Exception as e:
112
+ logger.warning(f"[Compositor] ffmpeg duration probe failed: {e}")
113
+
114
+ logger.warning(f"[Compositor] duration probe unavailable for {path}, using default {default}")
115
+ return default
116
+
117
+
118
+ def has_audio_stream(path: str) -> bool:
119
+ """检测媒体容器是否含音频流(Issue #78:不再依赖裸 ffprobe)。
120
+
121
+ 优先 ``ffprobe -select_streams a``;ffprobe 不可用时回退解析
122
+ ``ffmpeg -i`` 的 stderr 流信息(``Stream #0:1: Audio: ...``)。
123
+ 两者都不可用或探测异常时返回 ``False``(调用方按「无音频」保守处理)。
124
+
125
+ Args:
126
+ path: 媒体文件路径。
127
+
128
+ Returns:
129
+ 含音频流返回 True。
130
+ """
131
+ if not path or not os.path.exists(path):
132
+ return False
133
+
134
+ ffprobe = resolve_binary("ffprobe")
135
+ if ffprobe:
136
+ try:
137
+ r = subprocess.run(
138
+ [ffprobe, "-v", "error", "-select_streams", "a",
139
+ "-show_entries", "stream=codec_type", "-of", "csv=p=0", path],
140
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15,
141
+ )
142
+ if r.returncode == 0:
143
+ return "audio" in (r.stdout or "")
144
+ except Exception as e:
145
+ logger.warning(f"[Compositor] ffprobe audio-stream probe failed: {e}")
146
+
147
+ ffmpeg = resolve_binary("ffmpeg")
148
+ if ffmpeg:
149
+ try:
150
+ r = subprocess.run(
151
+ [ffmpeg, "-hide_banner", "-i", path],
152
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15,
153
+ )
154
+ if re.search(r"Stream #\S+.*?: Audio:", r.stderr or ""):
155
+ return True
156
+ except Exception as e:
157
+ logger.warning(f"[Compositor] ffmpeg audio-stream probe failed: {e}")
158
+
159
+ return False
160
+
161
+
162
+ def probe_video_dimensions(path: str) -> "tuple[int | None, int | None]":
163
+ """探测视频宽高(像素),失败返回 ``(None, None)``(Issue #78)。
164
+
165
+ 优先 ``ffprobe`` JSON 输出;ffprobe 不可用时回退解析 ``ffmpeg -i``
166
+ stderr 里的 ``768x1152`` 分辨率描述。
167
+ """
168
+ if not path or not os.path.exists(path):
169
+ return None, None
170
+
171
+ ffprobe = resolve_binary("ffprobe")
172
+ if ffprobe:
173
+ try:
174
+ r = subprocess.run(
175
+ [ffprobe, "-v", "quiet", "-print_format", "json",
176
+ "-show_streams", path],
177
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30,
178
+ )
179
+ import json
180
+ info = json.loads(r.stdout or "{}")
181
+ for s in info.get("streams", []):
182
+ if s.get("codec_type") == "video":
183
+ return s.get("width"), s.get("height")
184
+ except Exception as e:
185
+ logger.warning(f"[Compositor] ffprobe size probe failed: {e}")
186
+
187
+ ffmpeg = resolve_binary("ffmpeg")
188
+ if ffmpeg:
189
+ try:
190
+ r = subprocess.run(
191
+ [ffmpeg, "-hide_banner", "-i", path],
192
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30,
193
+ )
194
+ m = re.search(r"Video:.*?(\d{2,5})x(\d{2,5})", r.stderr or "")
195
+ if m:
196
+ return int(m.group(1)), int(m.group(2))
197
+ except Exception as e:
198
+ logger.warning(f"[Compositor] ffmpeg size probe failed: {e}")
199
+
200
+ logger.warning(f"[Compositor] cannot detect dimensions: {path}")
201
+ return None, None
202
+
203
+
55
204
  def _resolve(name: str) -> "str | None":
56
205
  # 1) 显式指定
57
206
  override = os.environ.get(_EXE_OVERRIDE[name])
@@ -99,4 +248,35 @@ def _sibling(exe: str, stem: str) -> "str | None":
99
248
  candidate = os.path.join(
100
249
  os.path.dirname(exe), stem + (".exe" if os.name == "nt" else "")
101
250
  )
102
- return candidate if os.path.exists(candidate) else None
251
+ return candidate if os.path.exists(candidate) else None
252
+
253
+
254
+ def resolve_cmd_binary(cmd: list) -> list:
255
+ """把命令列表首元素里的裸 ``ffmpeg`` / ``ffprobe`` 替换为解析后的绝对路径。
256
+
257
+ Issue #78:多处调用方按 ``["ffmpeg", "-y", ...]`` 拼命令,未统一走
258
+ ``resolve_binary``。传入本函数后:
259
+
260
+ - 首元素是 ``ffmpeg`` / ``ffprobe``(含带路径后缀的写法)→ 替换为真实路径;
261
+ - 解析不到可执行文件 → 抛 ``RuntimeError``(i18n 文案),而非在下游
262
+ 抛不可读的 ``[WinError 2]`` / ``FileNotFoundError``;
263
+ - 其他命令(首元素非 ffmpeg 系)原样返回,不做任何假设。
264
+
265
+ Args:
266
+ cmd: 原始命令列表。
267
+
268
+ Returns:
269
+ 处理后的命令列表(新列表,不修改入参)。
270
+ """
271
+ if not cmd:
272
+ return cmd
273
+ head = os.path.basename(str(cmd[0]))
274
+ stem = os.path.splitext(head)[0].lower()
275
+ if stem not in _EXE_OVERRIDE:
276
+ return cmd
277
+ resolved = resolve_binary(stem)
278
+ if not resolved:
279
+ from core.i18n_backend import translate
280
+
281
+ raise RuntimeError(translate("error.ffmpeg_missing"))
282
+ return [resolved, *cmd[1:]]
@@ -11,7 +11,7 @@ import subprocess
11
11
  import tempfile
12
12
  from typing import NamedTuple, Optional
13
13
 
14
- from core.compositor.ffmpeg_tool import resolve_binary
14
+ from core.compositor.ffmpeg_tool import probe_video_dimensions, resolve_binary
15
15
  from core.config import resolve_font_path
16
16
 
17
17
  logger = logging.getLogger(__name__)
@@ -62,19 +62,8 @@ def _build_watermark_text(language: str) -> str:
62
62
 
63
63
 
64
64
  def _get_video_dimensions(input_path: str) -> tuple:
65
- try:
66
- result = subprocess.run(
67
- ["ffprobe", "-v", "quiet", "-print_format", "json",
68
- "-show_streams", input_path],
69
- capture_output=True, text=True, timeout=30,
70
- )
71
- info = __import__('json').loads(result.stdout)
72
- for s in info.get("streams", []):
73
- if s.get("codec_type") == "video":
74
- return s["width"], s["height"]
75
- except Exception:
76
- pass
77
- return None, None
65
+ """探测视频宽高;Issue #78:统一走 ffmpeg_tool(ffprobe → ffmpeg 兜底)。"""
66
+ return probe_video_dimensions(input_path)
78
67
 
79
68
 
80
69
  def _render_watermark_png(
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 = "7.0.4"
21
+ APP_VERSION = "7.0.6"
22
22
 
23
23
  # 未配置 API Key 时的统一报错文案(含免费获取与在线体验兜底,全站路由共用)
24
24
  #
@@ -247,6 +247,21 @@ CATALOG: Dict[str, Dict[str, str]] = {
247
247
  "en": "the Agnes service domain",
248
248
  },
249
249
 
250
+ # ── ffmpeg 缺失(core/audio/tts.py::SilentTTSEngine,Issue #78)──
251
+ "error.ffmpeg_missing": {
252
+ "zh": (
253
+ "未找到可用的 ffmpeg 可执行文件,无法生成音频/视频文件。"
254
+ "请安装 ffmpeg(Windows 可执行 winget install Gyan.FFmpeg)并确保它在 PATH 中,"
255
+ "或通过环境变量 FFMPEG_BINARY 指定其绝对路径,然后重试任务。"
256
+ ),
257
+ "en": (
258
+ "No usable ffmpeg executable was found, so audio/video files cannot be produced. "
259
+ "Please install ffmpeg (on Windows: winget install Gyan.FFmpeg), make sure it is on "
260
+ "your PATH, or point the FFMPEG_BINARY environment variable at its absolute path, "
261
+ "then retry the task."
262
+ ),
263
+ },
264
+
250
265
  # ── API Key 缺失(core/config.py::API_KEY_MISSING_MSG 的替代)──
251
266
  "config.api_key_missing": {
252
267
  "zh": (
@@ -9,7 +9,6 @@ import functools
9
9
  import json
10
10
  import logging
11
11
  import os
12
- import subprocess
13
12
  import time
14
13
  from abc import ABC, abstractmethod
15
14
  from types import SimpleNamespace
@@ -25,6 +24,7 @@ _ENCODING_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
25
24
  )
26
25
 
27
26
  from core.async_io import read_text, write_text
27
+ from core.compositor.ffmpeg_tool import probe_duration, resolve_cmd_binary
28
28
  from core.compositor.watermark import add_watermark, detect_language
29
29
  from core.config import get_watermark_config
30
30
  from core.i18n_backend import translate
@@ -517,21 +517,16 @@ class BasePipeline(ABC):
517
517
 
518
518
  @staticmethod
519
519
  def get_audio_duration(audio_path: str) -> float:
520
- """通过 ffprobe 获取音频文件时长(秒),失败返回 0.0。"""
520
+ """获取音频文件时长(秒),失败返回 0.0。
521
+
522
+ Issue #78:改用 ``ffmpeg_tool.probe_duration`` 统一解析可执行文件,
523
+ ffprobe 缺失时回退 ffmpeg stderr 解析,不再静默降级为 0.0。
524
+ """
521
525
  if not audio_path or not os.path.exists(audio_path):
522
526
  return 0.0
523
527
  if os.path.getsize(audio_path) == 0:
524
528
  return 0.0
525
- try:
526
- r = subprocess.run(
527
- ["ffprobe", "-v", "error", "-show_entries", "format=duration",
528
- "-of", "csv=p=0", audio_path],
529
- stdin=subprocess.DEVNULL,
530
- capture_output=True, text=True, timeout=15,
531
- )
532
- return float(r.stdout.strip())
533
- except Exception:
534
- return 0.0
529
+ return probe_duration(audio_path, default=0.0)
535
530
 
536
531
  async def generate_subtitles_common(
537
532
  self,
@@ -947,7 +942,12 @@ class BasePipeline(ABC):
947
942
 
948
943
  @staticmethod
949
944
  async def run_ffmpeg_async(cmd: List[str], timeout: float = 30.0) -> None:
950
- """异步执行 ffmpeg 命令(不阻塞事件循环)。等价于 subprocess.run(check=True)。"""
945
+ """异步执行 ffmpeg 命令(不阻塞事件循环)。等价于 subprocess.run(check=True)。
946
+
947
+ Issue #78:命令首元素为 ``"ffmpeg"``/``"ffprobe"`` 时统一解析为真实路径,
948
+ 避免在无系统 ffmpeg 的环境里抛裸 ``[WinError 2]``。
949
+ """
950
+ cmd = resolve_cmd_binary(cmd)
951
951
  proc = await asyncio.create_subprocess_exec(
952
952
  *cmd,
953
953
  stdout=asyncio.subprocess.PIPE,
@@ -6,6 +6,7 @@ import os
6
6
  import re
7
7
  from typing import List, Optional
8
8
 
9
+ from core.compositor.ffmpeg_tool import resolve_cmd_binary
9
10
  from core.pipelines import PipelineShutdown
10
11
  from models.task import StepStatus
11
12
  from utils.image_normalizer import PAD, normalize_image_async
@@ -52,6 +53,7 @@ async def _run_ffmpeg_async(cmd: List[str], timeout: float = 30.0) -> None:
52
53
  RuntimeError: ffmpeg 退出码非 0(等价原 ``check=True`` 语义)。
53
54
  asyncio.TimeoutError: 超时。
54
55
  """
56
+ cmd = resolve_cmd_binary(cmd)
55
57
  proc = await asyncio.create_subprocess_exec(
56
58
  *cmd,
57
59
  stdout=asyncio.subprocess.PIPE,
@@ -19,6 +19,7 @@ from core.api.agnes_video import AgnesVideoAPI
19
19
  from core.audio.subtitle import SubtitleGenerator
20
20
  from core.audio.tts import SilentTTSEngine
21
21
  from core.compositor.concatenator import VideoConcatenator
22
+ from core.compositor.ffmpeg_tool import has_audio_stream, probe_duration
22
23
  from core.config import DEFAULT_TEXT_MODEL
23
24
  from core.pipelines import MultiScenePipeline
24
25
  from core.screenwriter import Screenwriter, clean_narration_text
@@ -268,8 +269,6 @@ class PoetryVideoPipeline(MultiScenePipeline):
268
269
  可能提前截断(仅前半段有声音、后半段丢失且不抛异常)。
269
270
  因此每个场景间加入延迟,并在生成后校验音频时长。
270
271
  """
271
- import subprocess as _sp
272
-
273
272
  scenes = self._state.scenes
274
273
  audio_config = self._state.audio_config
275
274
  has_audio = audio_config.enabled
@@ -333,7 +332,7 @@ class PoetryVideoPipeline(MultiScenePipeline):
333
332
 
334
333
  # 时长校验(仅真实 EdgeTTS 产物):实际时长不足预期 60% → 删除 + Silent 重生成
335
334
  if has_audio:
336
- actual_dur = self._probe_duration(audio_path, _sp)
335
+ actual_dur = self._probe_duration(audio_path)
337
336
  if actual_dur is not None and actual_dur < min_dur * 0.6:
338
337
  logger.warning(
339
338
  f"[Poetry] scene {idx} audio incomplete "
@@ -355,38 +354,22 @@ class PoetryVideoPipeline(MultiScenePipeline):
355
354
  return None
356
355
 
357
356
  @staticmethod
358
- def _probe_duration(filepath: str, _sp=None) -> Optional[float]:
359
- """ffprobe 获取音频时长(秒),失败返回 None。"""
360
- try:
361
- if _sp is None:
362
- import subprocess as _sp2
363
- _sp = _sp2
364
- proc = _sp.run(
365
- ["ffprobe", "-v", "error", "-show_entries",
366
- "format=duration", "-of", "default=noprint_wrappers=1:nokey=1",
367
- filepath],
368
- capture_output=True, text=True, timeout=10,
369
- )
370
- if proc.returncode == 0 and proc.stdout.strip():
371
- return float(proc.stdout.strip())
372
- except Exception:
373
- pass
374
- return None
357
+ def _probe_duration(filepath: str) -> Optional[float]:
358
+ """获取音频时长(秒),失败返回 None。
359
+
360
+ Issue #78:改走 ``ffmpeg_tool.probe_duration``(ffprobe → ffmpeg 兜底),
361
+ 不再因 ffprobe 缺失而静默返回 None。
362
+ """
363
+ val = probe_duration(filepath, default=0.0)
364
+ return val if val > 0 else None
375
365
 
376
366
  @staticmethod
377
367
  def _has_audio_stream(filepath: str) -> bool:
378
- """ffprobe 检测视频文件是否包含音频流(防止复用不完整合成产物)。"""
379
- try:
380
- import subprocess as _sp
381
- proc = _sp.run(
382
- ["ffprobe", "-v", "error", "-select_streams", "a",
383
- "-show_entries", "stream=codec_type", "-of", "csv=p=0",
384
- filepath],
385
- capture_output=True, text=True, timeout=10,
386
- )
387
- return "audio" in proc.stdout
388
- except Exception:
389
- return False
368
+ """检测视频文件是否包含音频流(防止复用不完整合成产物)。
369
+
370
+ Issue #78:改走 ``ffmpeg_tool.has_audio_stream``(ffprobe → ffmpeg 兜底)。
371
+ """
372
+ return has_audio_stream(filepath)
390
373
 
391
374
  # ------------------------------------------------------------------
392
375
  # Phase 5: 字幕(逐场景 SRT,定时对齐朗诵)
package/models/task.py CHANGED
@@ -431,8 +431,8 @@ class PoetryVideoTask(BaseTaskState):
431
431
  # 可选:用户手动输入的分镜 prompt,按场景顺序每项一个;
432
432
  # 留空或某场景缺省时由 LLM 根据古诗生成。
433
433
  user_scene_prompts: List[str] = Field(default_factory=list)
434
- # 视觉风格(与创意视频保持一致,传入 LLM 分镜拆分)
435
- style: str = "电影质感写实风格"
434
+ # 视觉风格(与创意视频保持一致,传入 LLM 分镜拆分);留空时由编剧层给中性默认
435
+ style: str = ""
436
436
 
437
437
  # 配置(分辨率等参数与创意视频保持一致)
438
438
  video_width: int = 768
@@ -528,7 +528,7 @@ class CreateCreativeTaskRequest(BaseModel):
528
528
  """创建创意长视频任务的请求体"""
529
529
 
530
530
  idea: str
531
- style: str = "电影质感写实风格"
531
+ style: str = ""
532
532
  chaining_mode: str = "keyframes"
533
533
  video_width: int = 768
534
534
  video_height: int = 1152
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-short-video",
3
- "version": "7.0.4",
3
+ "version": "7.0.6",
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",