free-short-video 7.0.4 → 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 +5 -9
- package/core/audio/tts.py +9 -1
- package/core/compositor/ffmpeg_tool.py +181 -1
- package/core/compositor/watermark.py +3 -14
- package/core/config.py +1 -1
- package/core/i18n_backend.py +15 -0
- package/core/pipelines/__init__.py +13 -13
- package/core/pipelines/creative/steps_frames.py +2 -0
- package/core/pipelines/poetry_video.py +15 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
|
|
3
|
-
# What's New in v7.0.
|
|
3
|
+
# What's New in v7.0.5
|
|
4
4
|
|
|
5
5
|
## What's New
|
|
6
6
|
|
|
7
7
|
### Features & Improvements
|
|
8
8
|
|
|
9
|
-
* **
|
|
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
|
+
* **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.
|
|
15
10
|
|
|
16
11
|
### Bug Fixes
|
|
17
12
|
|
|
18
|
-
*
|
|
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.
|
|
19
15
|
|
|
20
16
|
---
|
|
21
17
|
|
|
22
|
-
|
|
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.
|
|
23
19
|
|
|
24
20
|
---
|
|
25
21
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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.
|
|
21
|
+
APP_VERSION = "7.0.5"
|
|
22
22
|
|
|
23
23
|
# 未配置 API Key 时的统一报错文案(含免费获取与在线体验兜底,全站路由共用)
|
|
24
24
|
#
|
package/core/i18n_backend.py
CHANGED
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
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
|
|
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
|
|
359
|
-
"""
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
"""
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
+
"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",
|