free-short-video 6.4.0 → 6.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,34 +1,13 @@
1
1
  ---
2
2
 
3
- # What's New in v6.4.0
3
+ # What's New in v6.4.1
4
4
 
5
5
  ## What's New
6
6
 
7
- ### Features & Improvements
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.
15
-
16
- ### Refactoring & Optimizations
17
-
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.
19
-
20
7
  ### Bug Fixes
21
8
 
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)
28
-
29
- ---
9
+ * **Fixed "ffmpeg not found" failure (`FileNotFoundError`) during video composition** — on Windows or systems where `ffmpeg` is not on the `PATH`, creative / manuscript / anchor / poetry composition (concatenation, audio overlay, watermark) could fail at the composition step with `[WinError 2]`. The server now automatically falls back to the static ffmpeg binary bundled with `imageio-ffmpeg`, so no manual ffmpeg installation is required. A non-blocking startup check logs which ffmpeg source is being used. (Issues #35, #36)
30
10
 
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
11
 
33
12
  ---
34
13
 
@@ -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]",
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 = "6.4.0"
21
+ APP_VERSION = "6.4.1"
22
22
 
23
23
  # 未配置 API Key 时的统一报错文案(含免费获取与在线体验兜底,全站路由共用)
24
24
  API_KEY_MISSING_MSG = (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-short-video",
3
- "version": "6.4.0",
3
+ "version": "6.4.1",
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",
package/server.py CHANGED
@@ -105,6 +105,21 @@ async def lifespan(app: FastAPI):
105
105
  except Exception as e:
106
106
  logger.warning(f"[Startup] Stale task sweep failed ({e})")
107
107
 
108
+ # ffmpeg 启动检测(非阻断):解析一次并打印来源;缺失仅告警、不阻止启动。
109
+ try:
110
+ from core.compositor.ffmpeg_tool import resolve_binary
111
+ ffmpeg_bin = resolve_binary("ffmpeg")
112
+ if ffmpeg_bin:
113
+ logger.info(f"[Compositor] ffmpeg resolved: {ffmpeg_bin}")
114
+ else:
115
+ logger.warning(
116
+ "[Compositor] ffmpeg NOT found (no system PATH nor builtin "
117
+ "imageio-ffmpeg). Video/audio composition will fail later; "
118
+ "install ffmpeg or verify imageio-ffmpeg dependency."
119
+ )
120
+ except Exception as e:
121
+ logger.warning(f"[Compositor] ffmpeg detection failed: {e}")
122
+
108
123
  yield
109
124
 
110
125