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.
Files changed (60) hide show
  1. package/README.md +6 -17
  2. package/core/api/agnes_chat.py +10 -6
  3. package/core/api/agnes_image.py +13 -7
  4. package/core/api/agnes_models.py +2 -2
  5. package/core/api/agnes_video.py +16 -9
  6. package/core/api/rate_limiter.py +11 -5
  7. package/core/compositor/concatenator/audio_overlay.py +10 -9
  8. package/core/compositor/concatenator/concat.py +10 -3
  9. package/core/compositor/ffmpeg_tool.py +102 -0
  10. package/core/compositor/processor.py +6 -4
  11. package/core/compositor/watermark.py +2 -1
  12. package/core/config.py +107 -17
  13. package/package.json +1 -1
  14. package/server.py +15 -0
  15. package/static/assets/ar-DD2gvMKw.js +1 -0
  16. package/static/assets/bn-DPcCDLpk.js +1 -0
  17. package/static/assets/de-DFxaRKtY.js +1 -0
  18. package/static/assets/es-DoMCctKF.js +1 -0
  19. package/static/assets/fa-BzEKx0PN.js +1 -0
  20. package/static/assets/fr-C0bWeTQ6.js +1 -0
  21. package/static/assets/hi-C_hrAd50.js +1 -0
  22. package/static/assets/id-BlECjGv5.js +1 -0
  23. package/static/assets/index-BNSSpVol.css +1 -0
  24. package/static/assets/index-DD5NtUvj.js +45 -0
  25. package/static/assets/it-BCF04BbE.js +1 -0
  26. package/static/assets/ja--ICvVBr-.js +1 -0
  27. package/static/assets/ko-DAjiyyWl.js +1 -0
  28. package/static/assets/ms-DamV6gQc.js +1 -0
  29. package/static/assets/nl-BOMKQzmH.js +1 -0
  30. package/static/assets/pt-CC05NXdy.js +1 -0
  31. package/static/assets/ru-DPWcNyU1.js +1 -0
  32. package/static/assets/th-9kl-UM3z.js +1 -0
  33. package/static/assets/tl-4mRVYM2I.js +1 -0
  34. package/static/assets/tr-dfD9cPiH.js +1 -0
  35. package/static/assets/ur-L0V50o2j.js +1 -0
  36. package/static/assets/vi-ChKtUMhj.js +1 -0
  37. package/static/index.html +2 -2
  38. package/web/routes/config_routes.py +126 -1
  39. package/static/assets/ar-Co0g8IHi.js +0 -1
  40. package/static/assets/bn-CEnXv-Ci.js +0 -1
  41. package/static/assets/de-DFCb-VQb.js +0 -1
  42. package/static/assets/es-BB_h_gjW.js +0 -1
  43. package/static/assets/fa-1AsF74IR.js +0 -1
  44. package/static/assets/fr-D46wN4E5.js +0 -1
  45. package/static/assets/hi-c8b3jY49.js +0 -1
  46. package/static/assets/id-BHQhGk9v.js +0 -1
  47. package/static/assets/index-B9DuV3Tq.css +0 -1
  48. package/static/assets/index-DA1ElcyA.js +0 -45
  49. package/static/assets/it-WSb6hkMy.js +0 -1
  50. package/static/assets/ja-CnXP4P5V.js +0 -1
  51. package/static/assets/ko-DHM410FB.js +0 -1
  52. package/static/assets/ms-BbdBN1mH.js +0 -1
  53. package/static/assets/nl-ClA4sipR.js +0 -1
  54. package/static/assets/pt-5buuwYmD.js +0 -1
  55. package/static/assets/ru-BeoxxbcJ.js +0 -1
  56. package/static/assets/th-D0e7n9S1.js +0 -1
  57. package/static/assets/tl-Ci8Qz9u5.js +0 -1
  58. package/static/assets/tr-BJqJ4N-5.js +0 -1
  59. package/static/assets/ur-BFgi64hX.js +0 -1
  60. 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.0
3
+ # What's New in v6.4.2
4
4
 
5
5
  ## What's New
6
6
 
7
7
  ### Features & Improvements
8
8
 
9
- - **Preview ("dry-run") endpoints for creative and manuscript tasks** — `POST /api/creative/preview-script` and `POST /api/manuscript/preview-split` let you inspect the generated script or manuscript segments before submitting a full task. Synchronous, no task created. Preview calls share the Chat API rate limiter and have a lightweight in-process concurrency cap (429 + Retry-After) to prevent abuse. (Credit: @Khaled97Sho, PR #33)
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
- ### Refactoring & Optimizations
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
- - **`split_manuscript_text()` extracted as a public utility** — the manuscript-splitting algorithm is now available as a standalone function, used by both the manuscript pipeline and the new preview-split endpoint.
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
- - **Fixed rate-limiter live-lock** — a rare synchronous acquire path race condition could stall all pipelines indefinitely. The token bucket's async path is now the primary path, with the sync path hardened to prevent the live-lock. (Credit: @Khaled97Sho, PR #33)
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
 
@@ -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 副本(从 KeyRing 轮转取 Key)。"""
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
- key = get_key_ring().next()
80
+ k = key or get_key_ring().next()
77
81
  h = dict(self._base_headers)
78
- h["Authorization"] = f"Bearer {key}"
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
- f"{get_agnes_base_url()}/chat/completions",
135
+ "/chat/completions",
132
136
  max_retries=_MAX_RETRIES,
133
137
  retry_base_delay=_RETRY_BASE_DELAY,
134
138
  json=payload,
@@ -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 get_agnes_base_url
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 副本(从 KeyRing 轮转取 Key)。"""
82
- key = get_key_ring().next()
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 {key}"
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"{get_agnes_base_url()}/images/generations",
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
  )
@@ -12,7 +12,7 @@ from core.config import (
12
12
  DEFAULT_IMAGE_MODEL,
13
13
  DEFAULT_TEXT_MODEL,
14
14
  DEFAULT_VIDEO_MODEL,
15
- get_agnes_base_url,
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"{get_agnes_base_url()}/models?all=true"
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}"},
@@ -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
- get_agnes_base_url,
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 副本(从 KeyRing 轮转取 Key)。"""
123
- key = get_key_ring().next()
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 {key}"
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"{get_agnes_base_url()}/images/generations",
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"{get_agnes_base_url()}/videos",
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
  ),
@@ -269,7 +269,7 @@ def reset_rate_limiter() -> None:
269
269
 
270
270
  def request_with_key_rotation(
271
271
  requester,
272
- url: str,
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
- url: 请求 URL。
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
- headers = {**base_headers, "Authorization": f"Bearer {ring.next()}"}
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]",