free-short-video 6.4.0 → 6.4.2
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 +6 -17
- package/core/api/agnes_chat.py +10 -6
- package/core/api/agnes_image.py +13 -7
- package/core/api/agnes_models.py +2 -2
- package/core/api/agnes_video.py +16 -9
- package/core/api/rate_limiter.py +11 -5
- package/core/compositor/concatenator/audio_overlay.py +10 -9
- package/core/compositor/concatenator/concat.py +10 -3
- package/core/compositor/ffmpeg_tool.py +102 -0
- package/core/compositor/processor.py +6 -4
- package/core/compositor/watermark.py +2 -1
- package/core/config.py +107 -17
- package/package.json +1 -1
- package/server.py +15 -0
- package/static/assets/ar-DD2gvMKw.js +1 -0
- package/static/assets/bn-DPcCDLpk.js +1 -0
- package/static/assets/de-DFxaRKtY.js +1 -0
- package/static/assets/es-DoMCctKF.js +1 -0
- package/static/assets/fa-BzEKx0PN.js +1 -0
- package/static/assets/fr-C0bWeTQ6.js +1 -0
- package/static/assets/hi-C_hrAd50.js +1 -0
- package/static/assets/id-BlECjGv5.js +1 -0
- package/static/assets/index-BNSSpVol.css +1 -0
- package/static/assets/index-DD5NtUvj.js +45 -0
- package/static/assets/it-BCF04BbE.js +1 -0
- package/static/assets/ja--ICvVBr-.js +1 -0
- package/static/assets/ko-DAjiyyWl.js +1 -0
- package/static/assets/ms-DamV6gQc.js +1 -0
- package/static/assets/nl-BOMKQzmH.js +1 -0
- package/static/assets/pt-CC05NXdy.js +1 -0
- package/static/assets/ru-DPWcNyU1.js +1 -0
- package/static/assets/th-9kl-UM3z.js +1 -0
- package/static/assets/tl-4mRVYM2I.js +1 -0
- package/static/assets/tr-dfD9cPiH.js +1 -0
- package/static/assets/ur-L0V50o2j.js +1 -0
- package/static/assets/vi-ChKtUMhj.js +1 -0
- package/static/index.html +2 -2
- package/web/routes/config_routes.py +126 -1
- package/static/assets/ar-Co0g8IHi.js +0 -1
- package/static/assets/bn-CEnXv-Ci.js +0 -1
- package/static/assets/de-DFCb-VQb.js +0 -1
- package/static/assets/es-BB_h_gjW.js +0 -1
- package/static/assets/fa-1AsF74IR.js +0 -1
- package/static/assets/fr-D46wN4E5.js +0 -1
- package/static/assets/hi-c8b3jY49.js +0 -1
- package/static/assets/id-BHQhGk9v.js +0 -1
- package/static/assets/index-B9DuV3Tq.css +0 -1
- package/static/assets/index-DA1ElcyA.js +0 -45
- package/static/assets/it-WSb6hkMy.js +0 -1
- package/static/assets/ja-CnXP4P5V.js +0 -1
- package/static/assets/ko-DHM410FB.js +0 -1
- package/static/assets/ms-BbdBN1mH.js +0 -1
- package/static/assets/nl-ClA4sipR.js +0 -1
- package/static/assets/pt-5buuwYmD.js +0 -1
- package/static/assets/ru-BeoxxbcJ.js +0 -1
- package/static/assets/th-D0e7n9S1.js +0 -1
- package/static/assets/tl-Ci8Qz9u5.js +0 -1
- package/static/assets/tr-BJqJ4N-5.js +0 -1
- package/static/assets/ur-BFgi64hX.js +0 -1
- package/static/assets/vi-DkyI43Z4.js +0 -1
package/README.md
CHANGED
|
@@ -1,34 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
|
|
3
|
-
# What's New in v6.4.
|
|
3
|
+
# What's New in v6.4.2
|
|
4
4
|
|
|
5
5
|
## What's New
|
|
6
6
|
|
|
7
7
|
### Features & Improvements
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- **Arabic tashkeel for TTS narration** — creative and manuscript pipelines now automatically apply Arabic diacritic marks (harakat) to narration text before TTS synthesis, improving pronunciation quality. Tashkeel only affects TTS input; subtitle text and `narration.txt` remain clean. (Credit: @Khaled97Sho, PR #33)
|
|
11
|
-
- **Manuscript reference images** — per-segment reference images for manuscript tasks are now supported via `reference_images` + `reference_images_map` parameters, matching the same feature used in creative pipelines. (Credit: @Khaled97Sho, PR #33)
|
|
12
|
-
- **Language-aware narration budget** — narration length for non-CJK languages now uses a language-specific characters-per-second rate (Arabic: 4.0, CJK: 5.0, others: 12.0), fixing the "narration too short for non-CJK text" issue. (Credit: @Khaled97Sho, PR #33)
|
|
13
|
-
- **Configurable CORS origins** — the `AGNES_CORS_ORIGINS` environment variable (comma-separated) allows any companion tool to call the core API cross-origin. `AGNES_CORS_ENABLED` can force-enable or disable; auto-detects when origins are set.
|
|
14
|
-
- **China domestic API domain** — the `cn` domain in `AGNES_DOMAIN_MAP` now correctly points to `api.agnes-ai.cn` (the official China service endpoint), updated from the incorrect `apihub.agnes-ai.cn` (international-site fallback). The frontend domain picker reflects the correct hostname.
|
|
9
|
+
* **Per-Key domain binding** — each API Key saved in the projects config can now carry its own access domain (`com` / `cn` / `cn_bak`), so keys issued on the global site and keys issued on the domestic site can coexist and be routed to the correct endpoint automatically. Keys can from the environment still use the global domain.
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
* **One-click "Auto-detect domains" button** — in the API Key config panel, trigger a probe that tests each key across the candidate domains and fills in the matching domain automatically, instead of guessing it by hand.
|
|
17
12
|
|
|
18
|
-
|
|
13
|
+
* **Restored China fallback domain** **`apihub.agnes-ai.cn`** — this endpoint accepts both domestic and global keys, giving users a migration path when their key domain changes. It is not listed in the official docs and may be removed later, so we recommend choosing the domain that matches your key.
|
|
19
14
|
|
|
20
15
|
### Bug Fixes
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
- **Fixed image save not persisting** — `POST /api/images/generations` now `await`s the image download, ensuring the generated image is actually written to disk before the response.
|
|
24
|
-
- **Fixed GitHub CodeQL alerts #43 and #47** — path-injection and information-disclosure security alerts resolved.
|
|
25
|
-
- **Fixed PBKDF2-HMAC-SHA256 for config key IDs** — config key IDs are now hashed with a proper key-derivation function instead of weaker hashing.
|
|
26
|
-
- **Fixed Docker Hub Cloudflare WAF blocking** — the Docker Hub overview update script now strips `<script>` blocks and escapes `<` in the JSON payload to bypass the WAF XSS rule.
|
|
27
|
-
- **Fixed China domestic site domain** — `AGNES_DOMAIN_MAP["cn"]` corrected to `api.agnes-ai.cn` per the official Agnes model catalog. (Issue #37)
|
|
17
|
+
* **Fixed multi-key authentication failures across domains** — after the Chinese domain migration, global-site keys could fail authentication against the strict domestic endpoint; domain is now resolved per key with the restored fallback, so mixed global/domestic keys authenticate successfully.
|
|
28
18
|
|
|
29
|
-
|
|
19
|
+
* **Domain mismatch hints in the config UI** — keys without a bound domain now show a clear "domain not set" hint, prompting users to pick or auto-detect the matching domain on their next visit.
|
|
30
20
|
|
|
31
|
-
**Compatibility notes**: The `core/audio/tashkeel.py` module adds a new optional dependency (`mishkal`). It is installed by default via `requirements.txt`; if missing, tashkeel silently falls back to the original text. Existing task state files are forward-compatible; no migration needed.
|
|
32
21
|
|
|
33
22
|
---
|
|
34
23
|
|
package/core/api/agnes_chat.py
CHANGED
|
@@ -16,7 +16,6 @@ import requests
|
|
|
16
16
|
|
|
17
17
|
from core.api.error_collector import collect_error, collect_error_from_exception
|
|
18
18
|
from core.api.rate_limiter import get_rate_limiter, request_with_key_rotation
|
|
19
|
-
from core.config import get_agnes_base_url
|
|
20
19
|
|
|
21
20
|
logger = logging.getLogger(__name__)
|
|
22
21
|
|
|
@@ -69,13 +68,18 @@ class AgnesChatAPI:
|
|
|
69
68
|
# 向后兼容:旧调用方可能读取 self.headers
|
|
70
69
|
self.headers = dict(self._base_headers)
|
|
71
70
|
|
|
72
|
-
def _auth_headers(self) -> dict:
|
|
73
|
-
"""每次请求前生成带当前 Key 的 headers
|
|
71
|
+
def _auth_headers(self, key: str | None = None) -> dict:
|
|
72
|
+
"""每次请求前生成带当前 Key 的 headers 副本。
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
key: 显式指定 Key(供按 Key 绑定域名路由时与 URL 保持一致)。
|
|
76
|
+
省略时从 KeyRing 轮转取当前 Key。
|
|
77
|
+
"""
|
|
74
78
|
from core.api.key_manager import get_key_ring
|
|
75
79
|
|
|
76
|
-
|
|
80
|
+
k = key or get_key_ring().next()
|
|
77
81
|
h = dict(self._base_headers)
|
|
78
|
-
h["Authorization"] = f"Bearer {
|
|
82
|
+
h["Authorization"] = f"Bearer {k}"
|
|
79
83
|
return h
|
|
80
84
|
|
|
81
85
|
def _image_to_b64_uri(self, path: str) -> str:
|
|
@@ -128,7 +132,7 @@ class AgnesChatAPI:
|
|
|
128
132
|
get_rate_limiter().acquire()
|
|
129
133
|
resp = request_with_key_rotation(
|
|
130
134
|
requests.post,
|
|
131
|
-
|
|
135
|
+
"/chat/completions",
|
|
132
136
|
max_retries=_MAX_RETRIES,
|
|
133
137
|
retry_base_delay=_RETRY_BASE_DELAY,
|
|
134
138
|
json=payload,
|
package/core/api/agnes_image.py
CHANGED
|
@@ -12,7 +12,7 @@ import requests
|
|
|
12
12
|
from core.api.error_collector import collect_error, collect_error_from_exception
|
|
13
13
|
from core.api.key_manager import get_key_ring
|
|
14
14
|
from core.api.rate_limiter import get_rate_limiter
|
|
15
|
-
from core.config import
|
|
15
|
+
from core.config import get_base_url_for_key
|
|
16
16
|
from utils.image import download_image
|
|
17
17
|
from utils.image_normalizer import normalize_reference_path
|
|
18
18
|
|
|
@@ -77,11 +77,16 @@ class AgnesImageAPI:
|
|
|
77
77
|
# 向后兼容:旧调用方可能读取 self.headers
|
|
78
78
|
self.headers = dict(self._base_headers)
|
|
79
79
|
|
|
80
|
-
def _auth_headers(self) -> dict:
|
|
81
|
-
"""每次请求前生成带当前 Key 的 headers
|
|
82
|
-
|
|
80
|
+
def _auth_headers(self, key: str | None = None) -> dict:
|
|
81
|
+
"""每次请求前生成带当前 Key 的 headers 副本。
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
key: 显式指定 Key(供按 Key 绑定域名路由时与 URL 保持一致)。
|
|
85
|
+
省略时从 KeyRing 轮转取当前 Key。
|
|
86
|
+
"""
|
|
87
|
+
k = key or get_key_ring().next()
|
|
83
88
|
h = dict(self._base_headers)
|
|
84
|
-
h["Authorization"] = f"Bearer {
|
|
89
|
+
h["Authorization"] = f"Bearer {k}"
|
|
85
90
|
return h
|
|
86
91
|
|
|
87
92
|
async def _path_to_b64(self, path: str) -> str:
|
|
@@ -156,10 +161,11 @@ class AgnesImageAPI:
|
|
|
156
161
|
await get_rate_limiter().acquire_async()
|
|
157
162
|
# 动态超时:第一次 120s,后续逐步增加(图像生成较慢,放宽读超时)
|
|
158
163
|
read_timeout = _READ_TIMEOUT_BASE_SECONDS * (attempt + 1)
|
|
164
|
+
key = ring.next()
|
|
159
165
|
resp = await asyncio.to_thread(
|
|
160
166
|
requests.post,
|
|
161
|
-
f"{
|
|
162
|
-
headers=self._auth_headers(),
|
|
167
|
+
f"{get_base_url_for_key(key)}/images/generations",
|
|
168
|
+
headers=self._auth_headers(key),
|
|
163
169
|
json=payload,
|
|
164
170
|
timeout=(30, read_timeout),
|
|
165
171
|
)
|
package/core/api/agnes_models.py
CHANGED
|
@@ -12,7 +12,7 @@ from core.config import (
|
|
|
12
12
|
DEFAULT_IMAGE_MODEL,
|
|
13
13
|
DEFAULT_TEXT_MODEL,
|
|
14
14
|
DEFAULT_VIDEO_MODEL,
|
|
15
|
-
|
|
15
|
+
get_base_url_for_key,
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
logger = logging.getLogger(__name__)
|
|
@@ -52,7 +52,7 @@ def fetch_available_models(api_key: str) -> dict:
|
|
|
52
52
|
接口失败(网络/鉴权/非 200)时返回硬编码兜底列表。
|
|
53
53
|
"""
|
|
54
54
|
try:
|
|
55
|
-
endpoint = f"{
|
|
55
|
+
endpoint = f"{get_base_url_for_key(api_key)}/models?all=true"
|
|
56
56
|
resp = requests.get(
|
|
57
57
|
endpoint,
|
|
58
58
|
headers={"Authorization": f"Bearer {api_key}"},
|
package/core/api/agnes_video.py
CHANGED
|
@@ -17,7 +17,7 @@ from core.api.rate_limiter import get_rate_limiter, get_video_submit_limiter
|
|
|
17
17
|
from core.config import (
|
|
18
18
|
VIDEO_ASPECT_RATIOS,
|
|
19
19
|
get_agnes_api_root,
|
|
20
|
-
|
|
20
|
+
get_base_url_for_key,
|
|
21
21
|
is_v25_video_model,
|
|
22
22
|
)
|
|
23
23
|
from utils.image_normalizer import normalize_reference_path
|
|
@@ -118,11 +118,16 @@ class AgnesVideoAPI:
|
|
|
118
118
|
# 向后兼容:旧调用方可能读取 self.headers
|
|
119
119
|
self.headers = dict(self._base_headers)
|
|
120
120
|
|
|
121
|
-
def _auth_headers(self) -> dict:
|
|
122
|
-
"""每次请求前生成带当前 Key 的 headers
|
|
123
|
-
|
|
121
|
+
def _auth_headers(self, key: str | None = None) -> dict:
|
|
122
|
+
"""每次请求前生成带当前 Key 的 headers 副本。
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
key: 显式指定 Key(供按 Key 绑定域名路由时与 URL 保持一致)。
|
|
126
|
+
省略时从 KeyRing 轮转取当前 Key。
|
|
127
|
+
"""
|
|
128
|
+
k = key or get_key_ring().next()
|
|
124
129
|
h = dict(self._base_headers)
|
|
125
|
-
h["Authorization"] = f"Bearer {
|
|
130
|
+
h["Authorization"] = f"Bearer {k}"
|
|
126
131
|
return h
|
|
127
132
|
|
|
128
133
|
def _path_to_b64(self, path: str) -> str:
|
|
@@ -202,10 +207,11 @@ class AgnesVideoAPI:
|
|
|
202
207
|
}
|
|
203
208
|
logger.info(f"[AgnesVideo] Uploading image to hosted URL (attempt {attempt + 1}/{retries})...")
|
|
204
209
|
await get_rate_limiter().acquire_async(self.shutdown_event)
|
|
210
|
+
key = ring.next()
|
|
205
211
|
resp = await asyncio.to_thread(
|
|
206
212
|
requests.post,
|
|
207
|
-
f"{
|
|
208
|
-
headers=self._auth_headers(),
|
|
213
|
+
f"{get_base_url_for_key(key)}/images/generations",
|
|
214
|
+
headers=self._auth_headers(key),
|
|
209
215
|
json=payload,
|
|
210
216
|
timeout=(30, 120),
|
|
211
217
|
)
|
|
@@ -405,11 +411,12 @@ class AgnesVideoAPI:
|
|
|
405
411
|
# 2.3 异步原生,停止可打断)
|
|
406
412
|
await get_video_submit_limiter().acquire_async(self.shutdown_event)
|
|
407
413
|
# M2: 缩短读超时从 180s 到 60s,使 stop() 更快生效
|
|
414
|
+
key = ring.next()
|
|
408
415
|
resp = await asyncio.wait_for(
|
|
409
416
|
asyncio.to_thread(
|
|
410
417
|
requests.post,
|
|
411
|
-
f"{
|
|
412
|
-
headers=self._auth_headers(),
|
|
418
|
+
f"{get_base_url_for_key(key)}/videos",
|
|
419
|
+
headers=self._auth_headers(key),
|
|
413
420
|
json=payload,
|
|
414
421
|
timeout=(15, 60),
|
|
415
422
|
),
|
package/core/api/rate_limiter.py
CHANGED
|
@@ -269,7 +269,7 @@ def reset_rate_limiter() -> None:
|
|
|
269
269
|
|
|
270
270
|
def request_with_key_rotation(
|
|
271
271
|
requester,
|
|
272
|
-
|
|
272
|
+
endpoint: str,
|
|
273
273
|
*,
|
|
274
274
|
max_retries: int = 3,
|
|
275
275
|
retry_base_delay: float = 20.0,
|
|
@@ -279,7 +279,8 @@ def request_with_key_rotation(
|
|
|
279
279
|
"""429 换 Key 立即重试;全 Key 429 或 5xx/超时/连接错误才指数退避。
|
|
280
280
|
|
|
281
281
|
规则:
|
|
282
|
-
1. 每请求前 key_ring.next()(round-robin,均匀分摊),生成带当前 Key 的 headers
|
|
282
|
+
1. 每请求前 key_ring.next()(round-robin,均匀分摊),生成带当前 Key 的 headers,
|
|
283
|
+
并按该 Key 绑定域名构造 URL(``get_base_url_for_key``,未绑定回退全局域名)
|
|
283
284
|
2. 429 且 has_multiple() -> key_ring.rotate() 立即重试(不 sleep、不计入退避)
|
|
284
285
|
—— Key 级隔离限速,换 Key 后配额是满的
|
|
285
286
|
3. 所有 Key 均 429(rotation 计数达到 len(keys) × 退避上限)-> 指数退避
|
|
@@ -287,7 +288,8 @@ def request_with_key_rotation(
|
|
|
287
288
|
|
|
288
289
|
Args:
|
|
289
290
|
requester: 可调用 (url, headers, **kw) -> requests.Response。
|
|
290
|
-
|
|
291
|
+
endpoint: API 接口相对路径(如 "/chat/completions")。完整 URL 依当前 Key
|
|
292
|
+
绑定域名经 ``get_base_url_for_key`` 动态拼接。
|
|
291
293
|
max_retries: 退避重试上限。
|
|
292
294
|
retry_base_delay: 指数退避基数(秒),delay = 基数 × (retries + 1)。
|
|
293
295
|
key_ring: KeyRing 实例;None 时取全局单例。
|
|
@@ -302,6 +304,8 @@ def request_with_key_rotation(
|
|
|
302
304
|
"""
|
|
303
305
|
import requests
|
|
304
306
|
|
|
307
|
+
from core.config import get_base_url_for_key
|
|
308
|
+
|
|
305
309
|
ring = key_ring or get_key_ring()
|
|
306
310
|
base_headers = requester_kwargs.pop("headers", None) or {}
|
|
307
311
|
retries = 0
|
|
@@ -309,8 +313,10 @@ def request_with_key_rotation(
|
|
|
309
313
|
max_rotations = len(ring) * max_retries
|
|
310
314
|
while True:
|
|
311
315
|
# 每请求前轮转 Key:round-robin 均匀分摊;429 换 Key(rotate 推进计数)后
|
|
312
|
-
# 下次 next() 自然取到下一个 Key
|
|
313
|
-
|
|
316
|
+
# 下次 next() 自然取到下一个 Key。URL 按该 Key 绑定域名动态拼接。
|
|
317
|
+
key = ring.next()
|
|
318
|
+
headers = {**base_headers, "Authorization": f"Bearer {key}"}
|
|
319
|
+
url = f"{get_base_url_for_key(key)}{endpoint}"
|
|
314
320
|
try:
|
|
315
321
|
resp = requester(url, headers=headers, **requester_kwargs)
|
|
316
322
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
@@ -13,6 +13,7 @@ from typing import List, Optional, Tuple
|
|
|
13
13
|
import srt as srt_lib
|
|
14
14
|
from moviepy import AudioFileClip, CompositeVideoClip, VideoFileClip
|
|
15
15
|
|
|
16
|
+
from core.compositor.ffmpeg_tool import resolve_binary
|
|
16
17
|
from models.task import SubtitleStyle
|
|
17
18
|
|
|
18
19
|
from .concat import _AUDIO_BITRATE, _AUDIO_CODEC, _AUDIO_FPS, _VIDEO_FPS
|
|
@@ -137,7 +138,7 @@ class AudioOverlayMixin:
|
|
|
137
138
|
tmp_files.append(extend_path)
|
|
138
139
|
pad_dur = final_dur - video_dur
|
|
139
140
|
VideoConcatenator._run_ffmpeg(
|
|
140
|
-
["ffmpeg", "-y",
|
|
141
|
+
[resolve_binary("ffmpeg"), "-y",
|
|
141
142
|
"-i", silent_path,
|
|
142
143
|
"-vf", f"tpad=stop_mode=clone:stop_duration={pad_dur:.2f}",
|
|
143
144
|
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
|
@@ -154,7 +155,7 @@ class AudioOverlayMixin:
|
|
|
154
155
|
tmp_files.append(apad_path)
|
|
155
156
|
pad_dur = final_dur - audio_dur
|
|
156
157
|
VideoConcatenator._run_ffmpeg(
|
|
157
|
-
["ffmpeg", "-y",
|
|
158
|
+
[resolve_binary("ffmpeg"), "-y",
|
|
158
159
|
"-i", audio_path,
|
|
159
160
|
"-af", f"apad=pad_dur={pad_dur:.2f},volume=1.5",
|
|
160
161
|
"-c:a", "libmp3lame", "-q:a", "2",
|
|
@@ -167,7 +168,7 @@ class AudioOverlayMixin:
|
|
|
167
168
|
vol_path = audio_path.replace(".mp3", "_vol.mp3")
|
|
168
169
|
tmp_files.append(vol_path)
|
|
169
170
|
VideoConcatenator._run_ffmpeg(
|
|
170
|
-
["ffmpeg", "-y",
|
|
171
|
+
[resolve_binary("ffmpeg"), "-y",
|
|
171
172
|
"-i", audio_path,
|
|
172
173
|
"-af", "volume=1.5",
|
|
173
174
|
"-c:a", "libmp3lame", "-q:a", "2",
|
|
@@ -290,7 +291,7 @@ class AudioOverlayMixin:
|
|
|
290
291
|
else:
|
|
291
292
|
v_chain = f"[0:v]tpad=stop_mode=clone:stop_duration={final_dur:.2f}[v]"
|
|
292
293
|
cmd = [
|
|
293
|
-
"ffmpeg", "-y",
|
|
294
|
+
resolve_binary("ffmpeg"), "-y",
|
|
294
295
|
"-i", silent_path,
|
|
295
296
|
"-i", audio_path,
|
|
296
297
|
"-filter_complex",
|
|
@@ -315,7 +316,7 @@ class AudioOverlayMixin:
|
|
|
315
316
|
"""ffprobe 获取视频宽高,失败回退 (768, 1152)。"""
|
|
316
317
|
try:
|
|
317
318
|
r = subprocess.run(
|
|
318
|
-
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
319
|
+
[resolve_binary("ffprobe"), "-v", "error", "-select_streams", "v:0",
|
|
319
320
|
"-show_entries", "stream=width,height",
|
|
320
321
|
"-of", "csv=s=x:p=0", video_path],
|
|
321
322
|
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15,
|
|
@@ -706,7 +707,7 @@ class AudioOverlayMixin:
|
|
|
706
707
|
"""
|
|
707
708
|
try:
|
|
708
709
|
n = len(audio_paths)
|
|
709
|
-
cmd = ["ffmpeg", "-y"]
|
|
710
|
+
cmd = [resolve_binary("ffmpeg"), "-y"]
|
|
710
711
|
for ap in audio_paths:
|
|
711
712
|
cmd += ["-i", ap]
|
|
712
713
|
filters = []
|
|
@@ -811,7 +812,7 @@ class AudioOverlayMixin:
|
|
|
811
812
|
|
|
812
813
|
# Step 1: Get clip duration
|
|
813
814
|
probe = subprocess.run(
|
|
814
|
-
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
815
|
+
[resolve_binary("ffprobe"), "-v", "error", "-show_entries", "format=duration",
|
|
815
816
|
"-of", "csv=p=0", clip_path],
|
|
816
817
|
stdin=subprocess.DEVNULL,
|
|
817
818
|
capture_output=True, text=True, timeout=15,
|
|
@@ -836,7 +837,7 @@ class AudioOverlayMixin:
|
|
|
836
837
|
# Step 4: Concatenate with xfade cross-fade transitions
|
|
837
838
|
try:
|
|
838
839
|
subprocess.run(
|
|
839
|
-
["ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
|
840
|
+
[resolve_binary("ffmpeg"), "-y", "-f", "concat", "-safe", "0",
|
|
840
841
|
"-i", concat_file,
|
|
841
842
|
"-c", "copy",
|
|
842
843
|
"-t", str(needed),
|
|
@@ -850,7 +851,7 @@ class AudioOverlayMixin:
|
|
|
850
851
|
# xfade filter 构建已由上方 trim 循环拼接替代(死代码,3.3 清理)
|
|
851
852
|
|
|
852
853
|
subprocess.run(
|
|
853
|
-
["ffmpeg", "-y",
|
|
854
|
+
[resolve_binary("ffmpeg"), "-y",
|
|
854
855
|
"-stream_loop", str(n - 1), "-i", clip_path,
|
|
855
856
|
"-filter_complex",
|
|
856
857
|
f"[0:v]trim=duration={needed}[v]",
|
|
@@ -11,6 +11,7 @@ from typing import List, Optional
|
|
|
11
11
|
import srt as srt_lib
|
|
12
12
|
from moviepy import VideoFileClip, concatenate_videoclips
|
|
13
13
|
|
|
14
|
+
from core.compositor.ffmpeg_tool import resolve_binary
|
|
14
15
|
from models.task import SubtitleStyle
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
@@ -114,7 +115,7 @@ class ConcatMixin:
|
|
|
114
115
|
sigs = set()
|
|
115
116
|
for p in video_paths:
|
|
116
117
|
r = subprocess.run(
|
|
117
|
-
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
118
|
+
[resolve_binary("ffprobe"), "-v", "error", "-select_streams", "v:0",
|
|
118
119
|
"-show_entries", "stream=width,height,avg_frame_rate",
|
|
119
120
|
"-of", "csv=s=x:p=0", p],
|
|
120
121
|
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15,
|
|
@@ -137,7 +138,7 @@ class ConcatMixin:
|
|
|
137
138
|
f.write(f"file '{esc}'\n")
|
|
138
139
|
try:
|
|
139
140
|
r = subprocess.run(
|
|
140
|
-
["ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
|
141
|
+
[resolve_binary("ffmpeg"), "-y", "-f", "concat", "-safe", "0",
|
|
141
142
|
"-i", concat_file, "-c", "copy", output_path],
|
|
142
143
|
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=600,
|
|
143
144
|
)
|
|
@@ -383,7 +384,7 @@ class ConcatMixin:
|
|
|
383
384
|
"""用 ffprobe 获取媒体文件时长(秒)。"""
|
|
384
385
|
try:
|
|
385
386
|
r = subprocess.run(
|
|
386
|
-
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
387
|
+
[resolve_binary("ffprobe"), "-v", "error", "-show_entries", "format=duration",
|
|
387
388
|
"-of", "csv=p=0", path],
|
|
388
389
|
stdin=subprocess.DEVNULL,
|
|
389
390
|
capture_output=True, text=True, timeout=15,
|
|
@@ -396,6 +397,12 @@ class ConcatMixin:
|
|
|
396
397
|
def _run_ffmpeg(cmd: list, desc: str = "") -> None:
|
|
397
398
|
"""执行 ffmpeg 命令,失败时抛 RuntimeError。"""
|
|
398
399
|
logger.info(f"[Compositor] ffmpeg: {desc}")
|
|
400
|
+
if not cmd or cmd[0] is None:
|
|
401
|
+
raise RuntimeError(
|
|
402
|
+
"ffmpeg not available (no system PATH ffmpeg nor builtin "
|
|
403
|
+
"imageio-ffmpeg). Please install ffmpeg or verify imageio-ffmpeg "
|
|
404
|
+
"is installed."
|
|
405
|
+
)
|
|
399
406
|
try:
|
|
400
407
|
r = subprocess.run(
|
|
401
408
|
cmd, stdin=subprocess.DEVNULL,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""core.compositor.ffmpeg_tool — ffmpeg / ffprobe 可执行文件统一解析。
|
|
2
|
+
|
|
3
|
+
背景:此前所有 ffmpeg/ffprobe 调用都用裸命令字符串经 ``subprocess.run`` 执行,
|
|
4
|
+
依赖系统 PATH 搜索可执行文件。Windows 未安装 ffmpeg 时 ``CreateProcess`` 抛
|
|
5
|
+
``[WinError 2] The system cannot find the file specified``,且发生在任务运行到
|
|
6
|
+
拼接步骤而非启动时(见 Issue #36 的完整 traceback)。
|
|
7
|
+
|
|
8
|
+
解析优先级(推荐,但不强制用户安装系统 ffmpeg):
|
|
9
|
+
1. 环境变量显式指定:``FFMPEG_BINARY`` / ``FFPROBE_BINARY`` —— 尊重用户意图
|
|
10
|
+
2. 系统 PATH(``shutil.which``)—— 已安装则用之,绝不覆盖
|
|
11
|
+
3. ``imageio-ffmpeg`` 内置静态二进制兜底 —— wheel 自带,无需用户操作
|
|
12
|
+
|
|
13
|
+
结果带进程级缓存;全部不可用时返回 None,由启动检测 / 调用方给出清晰指引
|
|
14
|
+
而非裸的 ``[WinError 2]``。
|
|
15
|
+
"""
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# 二进制名 → 覆盖环境变量名
|
|
23
|
+
_EXE_OVERRIDE = {"ffmpeg": "FFMPEG_BINARY", "ffprobe": "FFPROBE_BINARY"}
|
|
24
|
+
_cache: "dict[str, str | None]" = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_binary(name: str) -> "str | None":
|
|
28
|
+
"""按优先级解析 ffmpeg/ffprobe 可执行文件绝对路径,进程内缓存。
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
name: ``"ffmpeg"`` 或 ``"ffprobe"``。
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
可用可执行文件绝对路径;全部不可用时返回 None。
|
|
35
|
+
"""
|
|
36
|
+
if name not in _EXE_OVERRIDE:
|
|
37
|
+
raise ValueError(f"unknown binary: {name}")
|
|
38
|
+
if name in _cache:
|
|
39
|
+
return _cache[name]
|
|
40
|
+
path = _resolve(name)
|
|
41
|
+
_cache[name] = path
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_ffmpeg() -> "str | None":
|
|
46
|
+
"""便捷:解析 ffmpeg。"""
|
|
47
|
+
return resolve_binary("ffmpeg")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_ffprobe() -> "str | None":
|
|
51
|
+
"""便捷:解析 ffprobe(可能为 None,仅时长/尺寸探测用,调用方自带兜底)。"""
|
|
52
|
+
return resolve_binary("ffprobe")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve(name: str) -> "str | None":
|
|
56
|
+
# 1) 显式指定
|
|
57
|
+
override = os.environ.get(_EXE_OVERRIDE[name])
|
|
58
|
+
if override and os.path.exists(override):
|
|
59
|
+
logger.info(f"[Compositor] {name}: explicit {override}")
|
|
60
|
+
return override
|
|
61
|
+
|
|
62
|
+
# 2) 系统 PATH
|
|
63
|
+
found = shutil.which(name)
|
|
64
|
+
if found:
|
|
65
|
+
logger.info(f"[Compositor] {name}: system {found}")
|
|
66
|
+
return found
|
|
67
|
+
|
|
68
|
+
# 3) imageio-ffmpeg 内置(仅自带 ffmpeg;ffprobe 从同目录推导)
|
|
69
|
+
base = _cache.get("ffmpeg") or _resolve_builtin_ffmpeg()
|
|
70
|
+
if base:
|
|
71
|
+
if name == "ffmpeg":
|
|
72
|
+
return base
|
|
73
|
+
probe = _sibling(base, "ffprobe")
|
|
74
|
+
if probe:
|
|
75
|
+
logger.info(f"[Compositor] {name}: builtin {probe}")
|
|
76
|
+
return probe
|
|
77
|
+
|
|
78
|
+
logger.warning(f"[Compositor] {name}: not found (no system PATH nor builtin)")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_builtin_ffmpeg() -> "str | None":
|
|
83
|
+
"""取 imageio-ffmpeg 内置静态二进制;加载/失败时记录并返回 None。"""
|
|
84
|
+
try:
|
|
85
|
+
import imageio_ffmpeg
|
|
86
|
+
|
|
87
|
+
exe = imageio_ffmpeg.get_ffmpeg_exe()
|
|
88
|
+
except Exception as e: # 加载失败 / 内置缺失 / 首次下载失败
|
|
89
|
+
logger.warning(f"[Compositor] builtin ffmpeg unavailable: {e}")
|
|
90
|
+
return None
|
|
91
|
+
if exe and os.path.exists(exe):
|
|
92
|
+
logger.info(f"[Compositor] ffmpeg: builtin {exe}")
|
|
93
|
+
return exe
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _sibling(exe: str, stem: str) -> "str | None":
|
|
98
|
+
"""由可执行文件路径推导同目录兄弟程序(Windows 补 .exe)。"""
|
|
99
|
+
candidate = os.path.join(
|
|
100
|
+
os.path.dirname(exe), stem + (".exe" if os.name == "nt" else "")
|
|
101
|
+
)
|
|
102
|
+
return candidate if os.path.exists(candidate) else None
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
8
|
|
|
9
|
+
from core.compositor.ffmpeg_tool import resolve_binary
|
|
10
|
+
|
|
9
11
|
logger = logging.getLogger(__name__)
|
|
10
12
|
|
|
11
13
|
|
|
@@ -20,7 +22,7 @@ class VideoProcessor:
|
|
|
20
22
|
|
|
21
23
|
import subprocess
|
|
22
24
|
subprocess.run([
|
|
23
|
-
"ffmpeg", "-y", "-i", input_path,
|
|
25
|
+
resolve_binary("ffmpeg"), "-y", "-i", input_path,
|
|
24
26
|
"-vf", f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
|
25
27
|
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2",
|
|
26
28
|
"-c:v", "libx264", "-preset", "fast",
|
|
@@ -37,7 +39,7 @@ class VideoProcessor:
|
|
|
37
39
|
|
|
38
40
|
import subprocess
|
|
39
41
|
subprocess.run([
|
|
40
|
-
"ffmpeg", "-y",
|
|
42
|
+
resolve_binary("ffmpeg"), "-y",
|
|
41
43
|
"-sseof", "-1",
|
|
42
44
|
"-i", video_path,
|
|
43
45
|
"-frames:v", "1",
|
|
@@ -55,7 +57,7 @@ class VideoProcessor:
|
|
|
55
57
|
|
|
56
58
|
import subprocess
|
|
57
59
|
subprocess.run([
|
|
58
|
-
"ffmpeg", "-y",
|
|
60
|
+
resolve_binary("ffmpeg"), "-y",
|
|
59
61
|
"-f", "lavfi",
|
|
60
62
|
"-i", "anullsrc=r=44100:cl=mono",
|
|
61
63
|
"-t", str(duration_sec),
|
|
@@ -87,7 +89,7 @@ class VideoProcessor:
|
|
|
87
89
|
# 注意:勿再用 -t {freeze_duration},那会把整个输出截断成只剩冻结段。
|
|
88
90
|
try:
|
|
89
91
|
subprocess.run([
|
|
90
|
-
"ffmpeg", "-y",
|
|
92
|
+
resolve_binary("ffmpeg"), "-y",
|
|
91
93
|
"-i", video_path,
|
|
92
94
|
"-vf", f"tpad=stop_mode=clone:stop_duration={freeze_duration}",
|
|
93
95
|
"-c:v", "libx264", "-preset", "fast",
|
|
@@ -11,6 +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
15
|
from core.config import resolve_font_path
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
@@ -194,7 +195,7 @@ def add_watermark(
|
|
|
194
195
|
|
|
195
196
|
# ffmpeg overlay: 将水印 PNG 叠加到视频上,无损流式拷贝
|
|
196
197
|
cmd = [
|
|
197
|
-
"ffmpeg", "-y",
|
|
198
|
+
resolve_binary("ffmpeg"), "-y",
|
|
198
199
|
"-i", input_path,
|
|
199
200
|
"-i", wm_png_path,
|
|
200
201
|
"-filter_complex", f"[0:v][1:v]overlay={layout.pos_x}:{layout.pos_y}[v]",
|