free-short-video 5.1.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 (48) hide show
  1. package/.dockerignore +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +531 -0
  4. package/bin/cli.js +191 -0
  5. package/core/__init__.py +35 -0
  6. package/core/api/__init__.py +7 -0
  7. package/core/api/agnes_chat.py +294 -0
  8. package/core/api/agnes_image.py +256 -0
  9. package/core/api/agnes_models.py +84 -0
  10. package/core/api/agnes_video.py +535 -0
  11. package/core/api/error_collector.py +215 -0
  12. package/core/api/rate_limiter.py +142 -0
  13. package/core/artifacts.py +574 -0
  14. package/core/audio/__init__.py +9 -0
  15. package/core/audio/subtitle.py +1103 -0
  16. package/core/audio/tts.py +194 -0
  17. package/core/audio/voices.py +330 -0
  18. package/core/compositor/__init__.py +6 -0
  19. package/core/compositor/concatenator.py +653 -0
  20. package/core/compositor/processor.py +124 -0
  21. package/core/compositor/watermark.py +221 -0
  22. package/core/config.py +474 -0
  23. package/core/image_generator.py +8 -0
  24. package/core/path_security.py +58 -0
  25. package/core/pipeline.py +8 -0
  26. package/core/pipelines/__init__.py +466 -0
  27. package/core/pipelines/anchor_video.py +406 -0
  28. package/core/pipelines/creative_video.py +1968 -0
  29. package/core/pipelines/manuscript_video.py +540 -0
  30. package/core/pipelines/multi_scene.py +380 -0
  31. package/core/pipelines/poetry_video.py +504 -0
  32. package/core/pipelines/simple_video.py +143 -0
  33. package/core/screenwriter.py +1860 -0
  34. package/core/task_manager.py +211 -0
  35. package/core/video_generator.py +8 -0
  36. package/models/__init__.py +75 -0
  37. package/models/task.py +548 -0
  38. package/package.json +36 -0
  39. package/requirements.txt +13 -0
  40. package/resource/fonts/MicrosoftYaHeiNormal.ttc +0 -0
  41. package/resource/fonts/STHeitiMedium.ttc +0 -0
  42. package/server.py +2026 -0
  43. package/static/favicon.ico +0 -0
  44. package/static/icon.png +0 -0
  45. package/static/index.html +5277 -0
  46. package/utils/__init__.py +0 -0
  47. package/utils/image.py +36 -0
  48. package/utils/video.py +28 -0
package/bin/cli.js ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * free-short-video — npm launcher
6
+ *
7
+ * Installs/runs the full Agnes Video Generator (Python/FastAPI) service on the
8
+ * user's machine with zero manual setup beyond a Python 3.10+ interpreter:
9
+ * 1. detect python3 >= 3.10
10
+ * 2. create a venv inside the package
11
+ * 3. pip install -r requirements.txt (pulls moviepy + imageio-ffmpeg)
12
+ * 4. expose imageio-ffmpeg's static ffmpeg binary on PATH (no system ffmpeg needed)
13
+ * 5. spawn `python server.py` with PORT/HOST + optional AGNES_API_KEY
14
+ * 6. auto-open the browser, forward Ctrl+C for graceful shutdown
15
+ */
16
+
17
+ const { spawn, execSync } = require('child_process');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+
22
+ const PKG_ROOT = path.resolve(__dirname, '..');
23
+
24
+ function printHelp() {
25
+ console.log(`free-short-video — free AI short-video generator
26
+
27
+ Usage:
28
+ npx free-short-video [options]
29
+ fsv [options]
30
+
31
+ Options:
32
+ --port <n> Listen port (default 8765)
33
+ --host <h> Bind host (default 127.0.0.1; use 0.0.0.0 for LAN)
34
+ --no-open Do not auto-open the browser
35
+ -h, --help Show this help
36
+
37
+ Environment:
38
+ AGNES_API_KEY Your Agnes API key (can also be set later in the Web UI)
39
+
40
+ First run creates a local venv and installs Python dependencies automatically.
41
+ `);
42
+ }
43
+
44
+ // ── parse args ────────────────────────────────────────────────────────────
45
+ let port = 8765;
46
+ let host = '127.0.0.1';
47
+ let openBrowser = true;
48
+
49
+ const argv = process.argv.slice(2);
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i];
52
+ if (a === '--port') {
53
+ const v = parseInt(argv[++i], 10);
54
+ if (!Number.isNaN(v)) port = v;
55
+ } else if (a === '--host') {
56
+ host = argv[++i] || host;
57
+ } else if (a === '--no-open') {
58
+ openBrowser = false;
59
+ } else if (a === '-h' || a === '--help') {
60
+ printHelp();
61
+ process.exit(0);
62
+ } else {
63
+ console.error(`Unknown option: ${a}\n`);
64
+ printHelp();
65
+ process.exit(1);
66
+ }
67
+ }
68
+
69
+ // ── helpers ───────────────────────────────────────────────────────────────
70
+ function run(cmd, opts) {
71
+ return execSync(cmd, Object.assign({ stdio: 'inherit', cwd: PKG_ROOT }, opts));
72
+ }
73
+
74
+ function findPython() {
75
+ for (const cmd of ['python3', 'python']) {
76
+ try {
77
+ const out = execSync(`"${cmd}" --version`, { stdio: ['ignore', 'pipe', 'ignore'] })
78
+ .toString()
79
+ .trim();
80
+ const m = out.match(/Python (\d+)\.(\d+)/);
81
+ if (m && (parseInt(m[1], 10) > 3 || (parseInt(m[1], 10) === 3 && parseInt(m[2], 10) >= 10))) {
82
+ return cmd;
83
+ }
84
+ } catch (_) {
85
+ /* not found / wrong version */
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function venvBin(name) {
92
+ return process.platform === 'win32'
93
+ ? path.join(PKG_ROOT, '.venv', 'Scripts', `${name}.exe`)
94
+ : path.join(PKG_ROOT, '.venv', 'bin', name);
95
+ }
96
+
97
+ // ── 1. python check ───────────────────────────────────────────────────────
98
+ console.log('================================================');
99
+ console.log(' free-short-video');
100
+ console.log('================================================');
101
+ console.log('');
102
+
103
+ const py = findPython();
104
+ if (!py) {
105
+ console.error('❌ Python 3.10+ is required but was not found.');
106
+ console.error(' macOS: brew install python3');
107
+ console.error(' Ubuntu: sudo apt install python3 python3-venv');
108
+ console.error(' Windows: https://www.python.org/downloads/');
109
+ process.exit(1);
110
+ }
111
+ console.log(`✓ Using ${py}`);
112
+
113
+ const venvPython = venvBin('python');
114
+ const venvPip = venvBin('pip');
115
+
116
+ // ── 2. venv + deps ─────────────────────────────────────────────────────────
117
+ if (!fs.existsSync(venvPython)) {
118
+ console.log('[1/3] Creating virtual environment...');
119
+ run(`"${py}" -m venv "${path.join(PKG_ROOT, '.venv')}"`);
120
+ }
121
+
122
+ console.log('[2/3] Installing Python dependencies (first run only)...');
123
+ run(`"${venvPip}" install -q -r "${path.join(PKG_ROOT, 'requirements.txt')}"`, {
124
+ env: process.env,
125
+ });
126
+
127
+ // ── 3. ffmpeg via imageio-ffmpeg (no system ffmpeg required) ──────────────
128
+ let ffmpegDir = null;
129
+ try {
130
+ const exe = execSync(`"${venvPython}" -c "import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())"`, {
131
+ stdio: ['ignore', 'pipe', 'ignore'],
132
+ })
133
+ .toString()
134
+ .trim();
135
+ if (exe) ffmpegDir = path.dirname(exe);
136
+ } catch (_) {
137
+ /* imageio-ffmpeg missing is non-fatal; system ffmpeg may still work */
138
+ }
139
+ if (ffmpegDir) {
140
+ console.log('✓ Using bundled ffmpeg (imageio-ffmpeg)');
141
+ } else {
142
+ console.log('⚠ No bundled ffmpeg found; relying on system ffmpeg in PATH');
143
+ }
144
+
145
+ // ── 4. launch server ──────────────────────────────────────────────────────
146
+ const env = Object.assign({}, process.env, {
147
+ HOST: host,
148
+ PORT: String(port),
149
+ });
150
+ if (ffmpegDir) {
151
+ env.PATH = ffmpegDir + path.delimiter + (env.PATH || '');
152
+ }
153
+ if (process.env.AGNES_API_KEY) {
154
+ env.AGNES_API_KEY = process.env.AGNES_API_KEY;
155
+ }
156
+
157
+ console.log('[3/3] Starting service...');
158
+ console.log('');
159
+ console.log(` Web UI will be at http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`);
160
+ console.log(' Press Ctrl+C to stop.');
161
+ console.log('');
162
+
163
+ const child = spawn(venvPython, ['server.py'], {
164
+ cwd: PKG_ROOT,
165
+ env,
166
+ stdio: 'inherit',
167
+ });
168
+
169
+ child.on('exit', (code) => {
170
+ process.exit(code === null ? 0 : code);
171
+ });
172
+
173
+ const shutdown = (sig) => {
174
+ if (child.exitCode === null) child.kill(sig);
175
+ };
176
+ process.on('SIGINT', () => shutdown('SIGINT'));
177
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
178
+
179
+ // ── auto-open browser ─────────────────────────────────────────────────────
180
+ if (openBrowser) {
181
+ const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
182
+ setTimeout(() => {
183
+ try {
184
+ if (process.platform === 'darwin') execSync(`open "${url}"`);
185
+ else if (process.platform === 'win32') execSync(`start "" "${url}"`);
186
+ else execSync(`xdg-open "${url}"`);
187
+ } catch (_) {
188
+ /* browser open is best-effort */
189
+ }
190
+ }, 1500);
191
+ }
@@ -0,0 +1,35 @@
1
+ """core — Agnes Video Generator v2.0 核心模块
2
+
3
+ 导出所有子包的核心类和工具函数。
4
+ """
5
+
6
+ from core.api import AgnesImageAPI, AgnesVideoAPI, AgnesChatAPI
7
+ from core.audio import EdgeTTSEngine, SilentTTSEngine, SubtitleGenerator
8
+ from core.compositor import VideoConcatenator, VideoProcessor
9
+ from core.pipelines import (
10
+ BasePipeline,
11
+ PipelineShutdown,
12
+ SimpleVideoPipeline,
13
+ CreativeVideoPipeline,
14
+ ManuscriptVideoPipeline,
15
+ )
16
+
17
+ __all__ = [
18
+ # API 层
19
+ "AgnesImageAPI",
20
+ "AgnesVideoAPI",
21
+ "AgnesChatAPI",
22
+ # 音频层
23
+ "EdgeTTSEngine",
24
+ "SilentTTSEngine",
25
+ "SubtitleGenerator",
26
+ # 拼接层
27
+ "VideoConcatenator",
28
+ "VideoProcessor",
29
+ # 流水线层
30
+ "BasePipeline",
31
+ "PipelineShutdown",
32
+ "SimpleVideoPipeline",
33
+ "CreativeVideoPipeline",
34
+ "ManuscriptVideoPipeline",
35
+ ]
@@ -0,0 +1,7 @@
1
+ """core.api — Agnes AI API 调用层"""
2
+
3
+ from core.api.agnes_image import AgnesImageAPI, ImageOutput
4
+ from core.api.agnes_video import AgnesVideoAPI, VideoOutput
5
+ from core.api.agnes_chat import AgnesChatAPI
6
+
7
+ __all__ = ["AgnesImageAPI", "ImageOutput", "AgnesVideoAPI", "VideoOutput", "AgnesChatAPI"]
@@ -0,0 +1,294 @@
1
+ """core.api.agnes_chat — Agnes Chat API 封装(从 core/screenwriter.py 提取)
2
+
3
+ P5: 健壮 JSON 解析(strip_code_fence + 正则提取 + 降级重试)
4
+ P11: chat/chat_multimodal 统一重试(5xx/超时/连接错 3 次指数退避,4xx 不重试)
5
+ """
6
+
7
+ import base64
8
+ import json
9
+ import logging
10
+ import mimetypes
11
+ import os
12
+ import re
13
+ import time
14
+ from typing import List
15
+
16
+ import requests
17
+
18
+ from core.api.error_collector import collect_error, collect_error_from_exception
19
+ from core.api.rate_limiter import get_rate_limiter
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ BASE_URL = "https://apihub.agnes-ai.com/v1"
24
+
25
+ # 重试配置
26
+ _MAX_RETRIES = 3
27
+ _RETRY_BASE_DELAY = 15 # 秒,指数退避基数
28
+
29
+ # 正则:匹配首个 {…} 块(支持嵌套大括号)
30
+ _JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}")
31
+
32
+
33
+ def strip_code_fence(text: str) -> str:
34
+ """去除 LLM 响应中的代码围栏标记。
35
+
36
+ 处理常见变体:```json ... ```、``` ... ```、前后有多余空白/换行。
37
+
38
+ Args:
39
+ text: LLM 原始响应文本。
40
+
41
+ Returns:
42
+ 去除围栏后的文本。
43
+ """
44
+ text = text.strip()
45
+ # 去除首行 ``` 或 ```json 等
46
+ if text.startswith("```"):
47
+ first_newline = text.index("\n") if "\n" in text else len(text)
48
+ text = text[first_newline + 1:]
49
+ # 去除尾部 ```
50
+ if text.endswith("```"):
51
+ text = text[:-3]
52
+ return text.strip()
53
+
54
+
55
+ class AgnesChatAPI:
56
+ """Agnes LLM Chat API 封装(text + multimodal)。"""
57
+
58
+ def __init__(self, api_key: str, model: str = "agnes-2.0-flash"):
59
+ self.api_key = api_key
60
+ self.model = model
61
+ self.headers = {
62
+ "Authorization": f"Bearer {api_key}",
63
+ "Content-Type": "application/json",
64
+ }
65
+
66
+ def _image_to_b64_uri(self, path: str) -> str:
67
+ with open(path, "rb") as f:
68
+ b64 = base64.b64encode(f.read()).decode("utf-8")
69
+ mime = mimetypes.guess_type(path)[0] or "image/png"
70
+ return f"data:{mime};base64,{b64}"
71
+
72
+ @staticmethod
73
+ def _should_retry(resp: requests.Response) -> bool:
74
+ """判断 HTTP 响应是否应重试(5xx 和 429 重试,4xx 不重试)。"""
75
+ return resp.status_code >= 500 or resp.status_code == 429
76
+
77
+ @staticmethod
78
+ def _extract_prompt_from_payload(payload: dict) -> str:
79
+ """从 Chat payload 中提取 user prompt 文本(用于错误收集)。"""
80
+ messages = payload.get("messages", [])
81
+ for msg in reversed(messages):
82
+ content = msg.get("content", "")
83
+ if isinstance(content, list):
84
+ # 多模态:提取 text 部分
85
+ texts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
86
+ if texts:
87
+ return texts[0]
88
+ elif isinstance(content, str) and content.strip():
89
+ return content
90
+ return ""
91
+
92
+ def _request_with_retry(self, payload: dict, timeout: int = 120) -> dict:
93
+ """带重试的 API 请求。
94
+
95
+ 对 5xx/429/超时/连接错误进行最多 3 次指数退避重试。
96
+ 4xx 错误(非 429)直接抛出不重试。
97
+ 每次请求前通过全局限速器控制调用频率。
98
+
99
+ Args:
100
+ payload: 请求 JSON body。
101
+ timeout: 请求超时秒数。
102
+
103
+ Returns:
104
+ 解析后的响应 JSON dict。
105
+
106
+ Raises:
107
+ requests.HTTPError: 4xx 客户端错误或重试耗尽。
108
+ """
109
+ last_exc = None
110
+ for attempt in range(_MAX_RETRIES):
111
+ try:
112
+ get_rate_limiter().acquire()
113
+ resp = requests.post(
114
+ f"{BASE_URL}/chat/completions",
115
+ headers=self.headers,
116
+ json=payload,
117
+ timeout=timeout,
118
+ )
119
+ if self._should_retry(resp) and attempt < _MAX_RETRIES - 1:
120
+ delay = _RETRY_BASE_DELAY * (attempt + 1)
121
+ logger.warning(
122
+ f"[AgnesChat] Server error {resp.status_code}, "
123
+ f"retry {attempt + 1}/{_MAX_RETRIES} in {delay}s..."
124
+ )
125
+ collect_error(
126
+ "chat", "chat",
127
+ prompt=self._extract_prompt_from_payload(payload),
128
+ error_type=f"HTTP{resp.status_code}",
129
+ error_message=f"HTTP {resp.status_code}: server error",
130
+ status_code=resp.status_code,
131
+ response_body=resp.text,
132
+ retry_count=attempt + 1,
133
+ )
134
+ time.sleep(delay)
135
+ continue
136
+ resp.raise_for_status()
137
+ return resp.json()
138
+ except (requests.ConnectionError, requests.Timeout) as e:
139
+ last_exc = e
140
+ # 每次失败都记录(包括中间重试)
141
+ collect_error_from_exception(
142
+ "chat", "chat",
143
+ exc=e, prompt=self._extract_prompt_from_payload(payload),
144
+ retry_count=attempt + 1,
145
+ )
146
+ if attempt < _MAX_RETRIES - 1:
147
+ delay = _RETRY_BASE_DELAY * (attempt + 1)
148
+ logger.warning(
149
+ f"[AgnesChat] {type(e).__name__}, "
150
+ f"retry {attempt + 1}/{_MAX_RETRIES} in {delay}s..."
151
+ )
152
+ time.sleep(delay)
153
+ continue
154
+ raise
155
+ # 重试耗尽
156
+ if last_exc:
157
+ collect_error_from_exception(
158
+ "chat", "chat",
159
+ exc=last_exc, prompt=self._extract_prompt_from_payload(payload),
160
+ retry_count=_MAX_RETRIES,
161
+ )
162
+ raise last_exc
163
+ # 不可重试的 HTTP 错误(4xx 非 429)
164
+ try:
165
+ resp.raise_for_status() # type: ignore[possibly-undefined]
166
+ except requests.HTTPError as e:
167
+ collect_error(
168
+ "chat", "chat",
169
+ prompt=self._extract_prompt_from_payload(payload),
170
+ error_type=type(e).__name__,
171
+ error_message=str(e),
172
+ status_code=resp.status_code, # type: ignore[possibly-undefined]
173
+ response_body=resp.text[:5000], # type: ignore[possibly-undefined]
174
+ retry_count=_MAX_RETRIES,
175
+ )
176
+ raise
177
+ return resp.json() # type: ignore[possibly-undefined]
178
+
179
+ def chat(self, system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> str:
180
+ """纯文本 Chat 调用(含重试)。"""
181
+ logger.info(f"[AgnesChat] Calling chat ({self.model}), prompt: {len(user_prompt)} chars...")
182
+ data = self._request_with_retry(
183
+ {
184
+ "model": self.model,
185
+ "messages": [
186
+ {"role": "system", "content": system_prompt},
187
+ {"role": "user", "content": user_prompt},
188
+ ],
189
+ "temperature": 0.7,
190
+ "max_tokens": max_tokens,
191
+ },
192
+ timeout=120,
193
+ )
194
+ return data["choices"][0]["message"]["content"]
195
+
196
+ def chat_json(self, system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> dict:
197
+ """Chat 调用并解析 JSON 响应(健壮版)。
198
+
199
+ 处理流程:
200
+ 1. 调用 chat 获取文本
201
+ 2. strip_code_fence 去除围栏
202
+ 3. 尝试直接 json.loads
203
+ 4. 失败则用正则提取首个 {…} 块
204
+ 5. 仍失败则重试一次 chat 调用
205
+ 6. 最终失败抛出 ValueError
206
+
207
+ Args:
208
+ system_prompt: System 提示词。
209
+ user_prompt: User 提示词。
210
+ max_tokens: 最大生成 token 数。
211
+
212
+ Returns:
213
+ 解析后的 JSON dict。
214
+
215
+ Raises:
216
+ ValueError: JSON 解析最终失败。
217
+ """
218
+ for retry in range(2):
219
+ content = self.chat(system_prompt, user_prompt, max_tokens=max_tokens)
220
+ # Step 1: 去围栏
221
+ cleaned = strip_code_fence(content)
222
+ # Step 2: 直接解析
223
+ try:
224
+ return json.loads(cleaned)
225
+ except (json.JSONDecodeError, ValueError):
226
+ pass
227
+ # Step 3: 正则提取首个 {…} 块
228
+ match = _JSON_BLOCK_RE.search(cleaned)
229
+ if match:
230
+ try:
231
+ return json.loads(match.group())
232
+ except (json.JSONDecodeError, ValueError):
233
+ pass
234
+ # Step 4: 首轮失败则重试一次 chat 调用
235
+ if retry == 0:
236
+ logger.warning("[AgnesChat] JSON parse failed, retrying chat call...")
237
+ continue
238
+ # 最终失败
239
+ preview = content[:200]
240
+ error_msg = (
241
+ f"[AgnesChat] Failed to parse JSON after 2 attempts. "
242
+ f"Response preview: {preview}..."
243
+ )
244
+ collect_error(
245
+ "chat", "chat_json",
246
+ prompt=user_prompt, system_prompt=system_prompt,
247
+ error_type="JSONParseError",
248
+ error_message=error_msg,
249
+ response_body=content[:5000],
250
+ retry_count=2,
251
+ )
252
+ raise ValueError(error_msg)
253
+ # 不应到达此处,但保险起见
254
+ raise ValueError("[AgnesChat] Unexpected flow in chat_json")
255
+
256
+ def chat_multimodal(
257
+ self,
258
+ system_prompt: str,
259
+ text_prompt: str,
260
+ image_paths: List[str],
261
+ max_tokens: int = 4096,
262
+ ) -> str:
263
+ """多模态 Chat 调用(文本 + 图片,含重试)。"""
264
+ messages = [{"role": "system", "content": system_prompt}]
265
+
266
+ user_content = [{"type": "text", "text": text_prompt}]
267
+ for img_path in image_paths:
268
+ if img_path.startswith(("http://", "https://")):
269
+ user_content.append({
270
+ "type": "image_url",
271
+ "image_url": {"url": img_path},
272
+ })
273
+ elif os.path.exists(img_path):
274
+ b64_uri = self._image_to_b64_uri(img_path)
275
+ user_content.append({
276
+ "type": "image_url",
277
+ "image_url": {"url": b64_uri},
278
+ })
279
+ messages.append({"role": "user", "content": user_content})
280
+
281
+ logger.info(
282
+ f"[AgnesChat] Calling multimodal ({self.model}), "
283
+ f"{len(image_paths)} image(s), prompt: {len(text_prompt)} chars..."
284
+ )
285
+ data = self._request_with_retry(
286
+ {
287
+ "model": self.model,
288
+ "messages": messages,
289
+ "temperature": 0.7,
290
+ "max_tokens": max_tokens,
291
+ },
292
+ timeout=300,
293
+ )
294
+ return data["choices"][0]["message"]["content"]