free-short-video 7.0.3 → 7.0.5

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/README.md CHANGED
@@ -1,26 +1,21 @@
1
1
  ---
2
2
 
3
- # What's New in v7.0.3
3
+ # What's New in v7.0.5
4
4
 
5
5
  ## What's New
6
6
 
7
7
  ### Features & Improvements
8
8
 
9
- * **Survive Agnes queue saturation.** When Agnes rejects submissions with `503 video_queue_full` (the free Video 2.5 Flash queue can stay full for 10+ minutes), the app now retries on a dedicated track — every 30–60s for up to 900s (`AGNES_VIDEO_QUEUE_RETRY_SECONDS`) — without consuming the normal retry budget, so you no longer get a premature "generation failed" while you were simply never queued. Nothing is consumed by a rejected submit. The progress panel shows it live: `Agnes 视频队列已满(HTTP 503 · video_queue_full),正在排队重试(第 N 次 / 已等 X 分钟)。建议错峰重试或稍后再试。`
10
- * **Honest error messages.** Upstream failures now surface the real reason instead of a generic `HTTP 503: server error` or a raw dict dump: the submitted error's `code` and `message` are kept end-to-end (e.g. `ComfyUI internal error: inference not finished after 15 minutes (code=500)`), so "queue full" and "queued but stuck" are no longer indistinguishable. `error_logs/` stores readable text plus the upstream code.
11
- * **Queue notices in your language (all 22 locales).** Queue-full progress and failure notices are now rendered by the frontend from structured i18n keys, so Japanese, Arabic, Russian, etc. users see their own language instead of a Chinese fallback. The raw upstream error (HTTP status, `video_queue_full`) is quoted untranslated, as it comes from the API.
12
- * **Config panels start collapsed.** All four configuration cards (provider management, model selection, workspace, privacy) now start collapsed instead of expanded; your manual expand/collapse preference is still remembered per browser.
13
- * **Know when a model is not adapted.** Video models that appear in the upstream model list but have no capability entry in your installed version are now marked with `⚠` plus an explicit notice, instead of being silently submitted with the old v2.0 pixel protocol. The app version is shown in the page footer and returned by `GET /api/models`.
9
+ * **Readable, localized error when ffmpeg is missing.** If no usable ffmpeg executable can be found, the task now fails with a clear message in the user's UI language that names the problem and tells you how to fix it — install ffmpeg, make sure it is on `PATH`, or point the `FFMPEG_BINARY` environment variable at its absolute path — instead of the raw `[WinError 2]` that gave no clue about what was wrong.
14
10
 
15
11
  ### Bug Fixes
16
12
 
17
- * **Switching the text model provider back to Agnes now works.** Previously the switch only changed the UI: the backend kept the previously selected third-party provider and kept calling its endpoint (an empty value was dropped by the form layer and treated as "no change"). The selection is now sent explicitly, and it persists when you click **Save**.
18
- * **Frontend no longer breaks after an update.** The app entry page is now served with `Cache-Control: no-cache`, so browsers can no longer keep a stale entry page that references deleted build assets — the "task list (or the whole app) silently stopped working after pulling a new version" failure mode is gone. After upgrading, do one hard refresh (`Cmd/Ctrl+Shift+R`); afterwards updates apply on normal reload.
19
- * Fixed the queue-full messaging to name Agnes, quote the raw upstream error, and include an actionable suggestion (retry off-peak), instead of the vague "upstream video queue is full".
13
+ * **The silent audio track no longer breaks tasks on machines without a system-wide ffmpeg.** Generating the silent narration track (used when narration is turned off, or as an automatic fallback when text-to-speech is unavailable) launched `ffmpeg` by name, so the task failed at the `audio` step with `[WinError 2] The system cannot find the file specified` even though video concatenation, watermarking and the rest of the app already used the bundled binary. Last-frame extraction and end-frame normalization had the same problem and are fixed as well.
14
+ * **Duration, audio-stream and resolution probes no longer degrade silently when `ffprobe` is missing.** In lean environments — notably the Docker image, which ships only the bundled `ffmpeg` — media probing fell back to empty values, so narration/subtitle timing, watermark placement and anchor compositing could quietly proceed on wrong assumptions. Probing now falls back to `ffmpeg -i` output and returns real values.
20
15
 
21
16
  ---
22
17
 
23
- No action is required after upgrading. If your tasks sit at 0% for a long time, that is the Agnes free queue — the app now retries and tells you so; off-peak retries or switching to Video 2.0 (separate queue) remain the practical workarounds.
18
+ After upgrading, tasks that previously failed in the `audio` step can be resumed from the task list and will continue from the failed step — no system-wide ffmpeg installation is required.
24
19
 
25
20
  ---
26
21
 
@@ -461,6 +461,12 @@ class AgnesVideoAPI:
461
461
  last_status = ""
462
462
  poll_count = 0
463
463
  consecutive_failures = 0
464
+ # U9(v7.0.4):提交返回的是网关路由键,任务记录入库可见有延迟——
465
+ # 轮询侧 404 "task not found" 是「任务还没准备好」的中间态而非错误
466
+ # (实测上游高峰期可见性延迟可从历史 ~20s 劣化到 10min+),单独计数、
467
+ # 只做周期性 info 日志,不进 error_logs、不消耗连续失败配额;
468
+ # 最终兜底是循环顶部的 max_poll_duration 整体超时。
469
+ not_found_count = 0
464
470
  start_time = asyncio.get_event_loop().time()
465
471
  # 2.5 系列查询需带 model_name(text 模式可省略,但带上更通用)
466
472
  model_param = f"&model_name={self.model}" if is_v25_video_model(self.model) else ""
@@ -511,6 +517,18 @@ class AgnesVideoAPI:
511
517
  logger.warning("[KeyRotation] HTTP 429 on poll, 换 Key 立即重试")
512
518
  continue
513
519
  break
520
+ # U9:404(task not found)= 记录尚未在网关任务库可见,中间态
521
+ if resp.status_code == 404:
522
+ not_found_count += 1
523
+ poll_count += 1 # 计入自适应间隔与周期日志的推进
524
+ if not_found_count == 1 or not_found_count % 10 == 0:
525
+ logger.info(
526
+ f"[AgnesVideo] Video {video_id[:16]}... not yet visible "
527
+ f"(404 task not found), poll #{not_found_count}, "
528
+ f"elapsed {elapsed:.0f}s"
529
+ )
530
+ await asyncio.sleep(_adaptive_poll_interval(interval, poll_count))
531
+ continue
514
532
  resp.raise_for_status()
515
533
  result = resp.json()
516
534
  status = result.get("status", "")
@@ -33,6 +33,63 @@ _WORKSPACE_ROOT: Optional[Path] = None
33
33
  # 用 contextvar 而非逐层传参,将改动面收敛到 API 模块内部(PRD FR9)。
34
34
  _TASK_ID_CTX: ContextVar[str] = ContextVar("error_collector_task_id", default="")
35
35
 
36
+ # ── GA 埋点:上游接口报错聚合(内存态,按 task_id 分组)──
37
+ # 结构:{task_id: {"{status_code}|{model_type}|{api_method}": {
38
+ # "status_code": int, "model_type": str, "api_method": str,
39
+ # "count": int, "first_ts": iso, "last_ts": iso}}}
40
+ #
41
+ # 设计说明:故意**不写入 task_state.json**——流水线持有自己的 TaskManager
42
+ # 缓存实例,每次 update_state 都会拿缓存快照整份覆盖落盘,error_collector
43
+ # 若单独写盘会被下一次流水线落盘冲掉。改为内存聚合 +
44
+ # GET /api/tasks/{id}(web/routes/task_routes.py)读取时合并下发,
45
+ # 前端轮询到新增量即上报 GA `api_error` 事件(503 趋势分析用)。
46
+ # 进程重启后内存聚合清零属预期:前端埋点本身只覆盖"页面开着"的窗口,
47
+ # 完整离线趋势仍以 error_logs/ 落盘文件为准。
48
+ _TASK_ERROR_AGG: dict = {}
49
+ _MAX_AGG_ENTRIES_PER_TASK = 32 # 按维度聚合后条目数上限,防异常状态码刷爆
50
+
51
+
52
+ def _bump_upstream_error(task_id: str, status_code: int, model_type: str, api_method: str) -> None:
53
+ """累加一次带 HTTP 状态码的上游报错(内存聚合,失败静默不影响主流程)。"""
54
+ try:
55
+ now = datetime.now().isoformat(timespec="seconds")
56
+ per_task = _TASK_ERROR_AGG.setdefault(task_id, {})
57
+ key = f"{status_code}|{model_type}|{api_method}"
58
+ entry = per_task.get(key)
59
+ if entry is None:
60
+ if len(per_task) >= _MAX_AGG_ENTRIES_PER_TASK:
61
+ logger.warning(
62
+ f"[ErrorCollector] Upstream error agg cap "
63
+ f"({_MAX_AGG_ENTRIES_PER_TASK}) reached for task {task_id}, dropping"
64
+ )
65
+ return
66
+ per_task[key] = {
67
+ "status_code": status_code,
68
+ "model_type": model_type,
69
+ "api_method": api_method,
70
+ "count": 1,
71
+ "first_ts": now,
72
+ "last_ts": now,
73
+ }
74
+ else:
75
+ entry["count"] += 1
76
+ entry["last_ts"] = now
77
+ except Exception as e:
78
+ logger.debug(f"[ErrorCollector] Failed to bump upstream error agg: {e}")
79
+
80
+
81
+ def get_task_upstream_errors(task_id: str) -> list:
82
+ """返回某任务聚合后的上游报错列表(按首次发生时间排序,供任务详情端点合并下发)。"""
83
+ per_task = _TASK_ERROR_AGG.get(task_id or "")
84
+ if not per_task:
85
+ return []
86
+ return sorted(per_task.values(), key=lambda e: e.get("first_ts", ""))
87
+
88
+
89
+ def clear_task_upstream_errors(task_id: str) -> None:
90
+ """任务删除时清理对应聚合(防内存缓慢增长)。"""
91
+ _TASK_ERROR_AGG.pop(task_id or "", None)
92
+
36
93
 
37
94
  def set_workspace_root(path: str) -> None:
38
95
  """设置错误日志存储的根目录(通常为激活的工作空间路径)。
@@ -198,6 +255,12 @@ def collect_error(
198
255
  with open(filepath, "w", encoding="utf-8") as f:
199
256
  json.dump(error_data, f, ensure_ascii=False, indent=2)
200
257
 
258
+ # GA 埋点:带 HTTP 状态码的报错同步累加进任务级内存聚合
259
+ if status_code and error_data["task_id"]:
260
+ _bump_upstream_error(
261
+ error_data["task_id"], status_code, model_type, api_method
262
+ )
263
+
201
264
  # 优化路线图 1.5b:按数量轮转,防止 error_logs 无限膨胀
202
265
  _rotate_error_logs(log_dir)
203
266
 
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.3"
21
+ APP_VERSION = "7.0.5"
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-short-video",
3
- "version": "7.0.3",
3
+ "version": "7.0.5",
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",