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.
- package/README.md +4 -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/models/task.py +3 -3
- package/package.json +1 -1
- package/static/assets/{ar-BGwnHyw2.js → ar-DDU7VnXA.js} +1 -1
- package/static/assets/{bn-BZ1SVmZv.js → bn-nY2VgxNf.js} +1 -1
- package/static/assets/de-t0gEvqFh.js +1 -0
- package/static/assets/es-BrDX0bsl.js +1 -0
- package/static/assets/{fa-DTgnsV-R.js → fa-DC45iYzo.js} +1 -1
- package/static/assets/fr-jNdU3xMA.js +1 -0
- package/static/assets/{hi-CiRTbxUR.js → hi-DU2jFclD.js} +1 -1
- package/static/assets/id-xVm_LLjT.js +1 -0
- package/static/assets/index-B_DQr-bu.css +1 -0
- package/static/assets/index-D06v2INE.js +45 -0
- package/static/assets/it-CNEcqCqv.js +1 -0
- package/static/assets/ja-BMrQ7ES8.js +1 -0
- package/static/assets/ko-DLcdCpqD.js +1 -0
- package/static/assets/ms-lz3IP0pE.js +1 -0
- package/static/assets/nl-JdaJ6OFu.js +1 -0
- package/static/assets/pt-BAJ6NELp.js +1 -0
- package/static/assets/{ru-CYIQhw4j.js → ru-DUk7xiI4.js} +1 -1
- package/static/assets/{th-BHjOht4_.js → th-DBGXh4cH.js} +1 -1
- package/static/assets/tl-cWM0TcNr.js +1 -0
- package/static/assets/tr-DY7yRLf-.js +1 -0
- package/static/assets/{ur-CNslvoMb.js → ur-CBrqZI5F.js} +1 -1
- package/static/assets/vi-BWMCwlNI.js +1 -0
- package/static/index.html +2 -2
- package/web/routes/task_creation_routes.py +4 -4
- package/static/assets/de-CUKZPeV-.js +0 -1
- package/static/assets/es-DKx-teoF.js +0 -1
- package/static/assets/fr-QDOIUFKu.js +0 -1
- package/static/assets/id-DVJD5Y5W.js +0 -1
- package/static/assets/index-D9sLMHUd.css +0 -1
- package/static/assets/index-KzCkI4Os.js +0 -45
- package/static/assets/it-BsQ3SshU.js +0 -1
- package/static/assets/ja-D9O41FnB.js +0 -1
- package/static/assets/ko-BH0PhAjf.js +0 -1
- package/static/assets/ms-qcRbwWeG.js +0 -1
- package/static/assets/nl-BuXaX-G6.js +0 -1
- package/static/assets/pt-CC3c_p8Q.js +0 -1
- package/static/assets/tl-CLPCfL8g.js +0 -1
- package/static/assets/tr-_PXfKikt.js +0 -1
- 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.
|
|
3
|
+
# What's New in v7.0.6
|
|
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
|
+
* **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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.6"
|
|
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/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.
|
|
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",
|