fible 0.1.0__py3-none-any.whl
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.
- fible/__init__.py +1 -0
- fible/__main__.py +4 -0
- fible/cli.py +218 -0
- fible/config.py +84 -0
- fible/pipeline/__init__.py +0 -0
- fible/pipeline/composer.py +188 -0
- fible/pipeline/matcher.py +95 -0
- fible/pipeline/segmenter.py +86 -0
- fible/pipeline/tts.py +59 -0
- fible-0.1.0.dist-info/METADATA +276 -0
- fible-0.1.0.dist-info/RECORD +13 -0
- fible-0.1.0.dist-info/WHEEL +4 -0
- fible-0.1.0.dist-info/entry_points.txt +2 -0
fible/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
fible/__main__.py
ADDED
fible/cli.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""fible — 一句话生成视频。 用法: fible "介绍一下量子计算" """
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich import print as rprint
|
|
14
|
+
|
|
15
|
+
from fible.pipeline.segmenter import segment_text
|
|
16
|
+
from fible.pipeline.tts import generate_all_speeches, list_voices, DEFAULT_VOICE
|
|
17
|
+
from fible.pipeline.matcher import download_materials
|
|
18
|
+
from fible.pipeline.composer import compose_video
|
|
19
|
+
from fible.config import (
|
|
20
|
+
text_hash, OUTPUT_DIR, SCRIPTS_DIR,
|
|
21
|
+
get_deepseek_api_key, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
SCRIPT_GEN_PROMPT = """你是一个专业的视频文案撰稿人。根据用户给出的主题,撰写一段适合口播配音的视频文案。
|
|
27
|
+
|
|
28
|
+
要求:
|
|
29
|
+
1. 长度控制在 300-800 字,适合 1-3 分钟的视频
|
|
30
|
+
2. 语言口语化、自然流畅,像在跟朋友聊天
|
|
31
|
+
3. 结构清晰:引入 → 展开 → 总结
|
|
32
|
+
4. 有信息密度,不要空话套话
|
|
33
|
+
5. 只输出文案正文,不要标题、署名、解释等任何额外内容"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _generate_script(prompt: str) -> str:
|
|
37
|
+
"""用 DeepSeek 根据 prompt 生成视频文案"""
|
|
38
|
+
from openai import OpenAI
|
|
39
|
+
|
|
40
|
+
print(" AI 正在为你撰写文案...\n")
|
|
41
|
+
client = OpenAI(
|
|
42
|
+
api_key=get_deepseek_api_key(),
|
|
43
|
+
base_url=DEEPSEEK_BASE_URL,
|
|
44
|
+
)
|
|
45
|
+
response = client.chat.completions.create(
|
|
46
|
+
model=DEEPSEEK_MODEL,
|
|
47
|
+
messages=[
|
|
48
|
+
{"role": "system", "content": SCRIPT_GEN_PROMPT},
|
|
49
|
+
{"role": "user", "content": prompt},
|
|
50
|
+
],
|
|
51
|
+
temperature=0.8,
|
|
52
|
+
max_tokens=2048,
|
|
53
|
+
)
|
|
54
|
+
return response.choices[0].message.content.strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_pipeline(text: str, args, start_time: float) -> str:
|
|
58
|
+
"""核心管线:文案 → 视频"""
|
|
59
|
+
session_id = text_hash(text)
|
|
60
|
+
|
|
61
|
+
if args.force:
|
|
62
|
+
rprint(f"[yellow]--force:清除会话 {session_id} 缓存[/yellow]")
|
|
63
|
+
base = str(OUTPUT_DIR)
|
|
64
|
+
for pattern in [
|
|
65
|
+
f"{session_id}_scene_*.mp3",
|
|
66
|
+
f"{session_id}_material_*.mp4",
|
|
67
|
+
f"{session_id}_scene_*.mp4",
|
|
68
|
+
f"{session_id}_concat_list.txt",
|
|
69
|
+
]:
|
|
70
|
+
for f in glob.glob(os.path.join(base, pattern)):
|
|
71
|
+
os.remove(f)
|
|
72
|
+
|
|
73
|
+
# Step 1: 分镜
|
|
74
|
+
rprint("\n[bold cyan]▸ Step 1/4: AI 分镜分析[/bold cyan]")
|
|
75
|
+
script = segment_text(text, force=args.force)
|
|
76
|
+
scenes = script["scenes"]
|
|
77
|
+
title = script.get("title", "未命名")
|
|
78
|
+
|
|
79
|
+
table = Table(title=f"分镜脚本: {title}")
|
|
80
|
+
table.add_column("序号", style="dim")
|
|
81
|
+
table.add_column("旁白摘要", max_width=40)
|
|
82
|
+
table.add_column("时长", justify="right")
|
|
83
|
+
table.add_column("关键词")
|
|
84
|
+
for s in scenes:
|
|
85
|
+
table.add_row(
|
|
86
|
+
str(s["index"]),
|
|
87
|
+
s["narration"][:35] + ("..." if len(s["narration"]) > 35 else ""),
|
|
88
|
+
f"{s.get('duration_sec', '?')}s",
|
|
89
|
+
", ".join(s.get("keywords_en", [])[:3])
|
|
90
|
+
)
|
|
91
|
+
console.print(table)
|
|
92
|
+
|
|
93
|
+
# Step 2: 配音
|
|
94
|
+
rprint("\n[bold cyan]▸ Step 2/4: 生成配音[/bold cyan]")
|
|
95
|
+
scenes = generate_all_speeches(scenes, session_id=session_id, voice=args.voice)
|
|
96
|
+
|
|
97
|
+
rprint(f" 完成 {len(scenes)} 段配音")
|
|
98
|
+
for s in scenes:
|
|
99
|
+
rprint(f" 分镜 {s['index']}: {s['narration'][:30]}... [{s.get('actual_duration_sec', 0):.1f}s]")
|
|
100
|
+
|
|
101
|
+
# Step 3: 素材匹配
|
|
102
|
+
if not args.no_material:
|
|
103
|
+
rprint("\n[bold cyan]▸ Step 3/4: 匹配素材[/bold cyan]")
|
|
104
|
+
scenes = download_materials(scenes, session_id=session_id)
|
|
105
|
+
matched = sum(1 for s in scenes if s.get("material_file"))
|
|
106
|
+
rprint(f" 匹配成功 {matched}/{len(scenes)} 个分镜")
|
|
107
|
+
|
|
108
|
+
# Step 4: 合成
|
|
109
|
+
rprint("\n[bold cyan]▸ Step 4/4: 合成视频[/bold cyan]")
|
|
110
|
+
output_path = compose_video(scenes, session_id=session_id, output_name=args.output)
|
|
111
|
+
|
|
112
|
+
elapsed = time.time() - start_time
|
|
113
|
+
rprint(Panel(f"[bold green]完成![/bold green]\n"
|
|
114
|
+
f"文件: {output_path}\n"
|
|
115
|
+
f"耗时: {elapsed:.1f}s"))
|
|
116
|
+
|
|
117
|
+
return output_path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main():
|
|
121
|
+
parser = argparse.ArgumentParser(
|
|
122
|
+
description="fible — 一句话生成视频",
|
|
123
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
124
|
+
epilog="""
|
|
125
|
+
用法:
|
|
126
|
+
fible "介绍量子计算的发展历程" AI 写稿 → 你确认 → 出片
|
|
127
|
+
fible -i 文案.txt 直接用已有文案
|
|
128
|
+
fible -i 文案.txt -o 视频.mp4 指定输出路径
|
|
129
|
+
fible -i 文案.txt --no-material 纯配音+字幕
|
|
130
|
+
fible -i 文案.txt --force 忽略缓存
|
|
131
|
+
fible --list-voices 查看可用音色
|
|
132
|
+
"""
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument("prompt", nargs="?", help="一句话主题,AI 自动生成文案")
|
|
135
|
+
parser.add_argument("-i", "--input", help="已有文案文件路径 (.txt)")
|
|
136
|
+
parser.add_argument("-o", "--output", default="output.mp4", help="输出视频路径")
|
|
137
|
+
parser.add_argument("-v", "--voice", default=DEFAULT_VOICE, help="配音音色")
|
|
138
|
+
parser.add_argument("--list-voices", action="store_true", help="列出可用配音音色")
|
|
139
|
+
parser.add_argument("--no-material", action="store_true", help="不使用素材")
|
|
140
|
+
parser.add_argument("--force", action="store_true", help="忽略缓存,强制重新生成")
|
|
141
|
+
|
|
142
|
+
args = parser.parse_args()
|
|
143
|
+
|
|
144
|
+
if args.list_voices:
|
|
145
|
+
rprint("[bold]可用配音音色:[/bold]\n")
|
|
146
|
+
list_voices()
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# 模式 1: -i 指定文案文件
|
|
150
|
+
if args.input:
|
|
151
|
+
if not os.path.exists(args.input):
|
|
152
|
+
rprint(f"[red]文件不存在: {args.input}[/red]")
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
with open(args.input, "r", encoding="utf-8") as f:
|
|
155
|
+
text = f.read().strip()
|
|
156
|
+
if not text:
|
|
157
|
+
rprint("[red]文案文件为空[/red]")
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
start_time = time.time()
|
|
161
|
+
_run_pipeline(text, args, start_time)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
# 模式 2: 传入 prompt,AI 生成文案
|
|
165
|
+
if not args.prompt:
|
|
166
|
+
parser.print_help()
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
prompt = args.prompt
|
|
170
|
+
rprint(Panel(f"[bold]fible[/bold]\n主题: {prompt}\n音色: {args.voice}"))
|
|
171
|
+
|
|
172
|
+
# 检查是否有已保存的匹配文案
|
|
173
|
+
import hashlib
|
|
174
|
+
prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()[:12]
|
|
175
|
+
saved_script_file = SCRIPTS_DIR / f"{prompt_hash}.txt"
|
|
176
|
+
|
|
177
|
+
if saved_script_file.exists() and not args.force:
|
|
178
|
+
with open(saved_script_file, "r", encoding="utf-8") as f:
|
|
179
|
+
text = f.read().strip()
|
|
180
|
+
rprint(f"\n[dim]找到已保存的文案 ({saved_script_file.name})[/dim]")
|
|
181
|
+
rprint(Panel(text, title="文案预览", border_style="dim"))
|
|
182
|
+
answer = input("\n使用这份文案?[Y/n] ").strip().lower()
|
|
183
|
+
if answer in ("", "y", "yes"):
|
|
184
|
+
start_time = time.time()
|
|
185
|
+
_run_pipeline(text, args, start_time)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# 生成新文案
|
|
189
|
+
text = _generate_script(prompt)
|
|
190
|
+
rprint(Panel(text, title="AI 生成的文案", border_style="green"))
|
|
191
|
+
|
|
192
|
+
print()
|
|
193
|
+
answer = input("确认使用这份文案生成视频?[Y/n/s(保存)] ").strip().lower()
|
|
194
|
+
|
|
195
|
+
if answer == "s":
|
|
196
|
+
saved_script_file.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
with open(saved_script_file, "w", encoding="utf-8") as f:
|
|
198
|
+
f.write(text)
|
|
199
|
+
rprint(f"[green]文案已保存到 {saved_script_file}[/green]")
|
|
200
|
+
answer2 = input("现在生成视频?[Y/n] ").strip().lower()
|
|
201
|
+
if answer2 not in ("", "y", "yes"):
|
|
202
|
+
rprint("下次运行相同主题时会复用这份文案。")
|
|
203
|
+
return
|
|
204
|
+
elif answer not in ("", "y", "yes"):
|
|
205
|
+
rprint("已取消。")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# 保存文案供复用
|
|
209
|
+
saved_script_file.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
with open(saved_script_file, "w", encoding="utf-8") as f:
|
|
211
|
+
f.write(text)
|
|
212
|
+
|
|
213
|
+
start_time = time.time()
|
|
214
|
+
_run_pipeline(text, args, start_time)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|
fible/config.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
# 按优先级加载 .env:当前目录 → ~/.fible/.env
|
|
9
|
+
load_dotenv(Path.cwd() / ".env")
|
|
10
|
+
load_dotenv(Path.home() / ".fible" / ".env")
|
|
11
|
+
|
|
12
|
+
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
|
13
|
+
DEEPSEEK_MODEL = "deepseek-chat"
|
|
14
|
+
|
|
15
|
+
# 工作目录:优先 FIBLE_HOME,否则 ~/.fible/
|
|
16
|
+
FIBLE_HOME = Path(os.getenv("FIBLE_HOME", Path.home() / ".fible"))
|
|
17
|
+
FIBLE_HOME.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
OUTPUT_DIR = FIBLE_HOME / "output"
|
|
20
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
CACHE_DIR = FIBLE_HOME / "cache"
|
|
23
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
SCRIPTS_DIR = FIBLE_HOME / "scripts"
|
|
26
|
+
SCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
# ffmpeg 中文字体路径(不同平台不同)
|
|
29
|
+
_FONT_CANDIDATES = [
|
|
30
|
+
"/System/Library/Fonts/STHeiti Medium.ttc", # macOS
|
|
31
|
+
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", # Linux
|
|
32
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", # Linux
|
|
33
|
+
"C:/Windows/Fonts/simhei.ttf", # Windows
|
|
34
|
+
]
|
|
35
|
+
FONT_PATH = next((f for f in _FONT_CANDIDATES if os.path.exists(f)), "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_deepseek_api_key():
|
|
39
|
+
key = os.getenv("DEEPSEEK_API_KEY")
|
|
40
|
+
if not key:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"请设置环境变量 DEEPSEEK_API_KEY。\n"
|
|
43
|
+
"注册: https://platform.deepseek.com/\n"
|
|
44
|
+
"方式: export DEEPSEEK_API_KEY=sk-xxx 或写入 ~/.fible/.env"
|
|
45
|
+
)
|
|
46
|
+
return key
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_pexels_api_key():
|
|
50
|
+
key = os.getenv("PEXELS_API_KEY")
|
|
51
|
+
if not key:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"请设置环境变量 PEXELS_API_KEY。\n"
|
|
54
|
+
"注册: https://www.pexels.com/api/\n"
|
|
55
|
+
"方式: export PEXELS_API_KEY=xxx 或写入 ~/.fible/.env"
|
|
56
|
+
)
|
|
57
|
+
return key
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def text_hash(text: str) -> str:
|
|
61
|
+
return hashlib.sha256(text.encode()).hexdigest()[:12]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_json(data: dict, filepath: Path | str):
|
|
65
|
+
path = Path(filepath)
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
68
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_json(filepath: Path | str) -> dict | None:
|
|
72
|
+
path = Path(filepath)
|
|
73
|
+
if path.exists():
|
|
74
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
75
|
+
return json.load(f)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_output_path(output_name: str) -> str:
|
|
80
|
+
p = Path(output_name)
|
|
81
|
+
if not p.is_absolute():
|
|
82
|
+
p = Path.cwd() / p
|
|
83
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
return str(p)
|
|
File without changes
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from fible.config import OUTPUT_DIR, FONT_PATH, resolve_output_path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def compose_video(scenes: list, session_id: str, output_name: str = "final.mp4") -> str:
|
|
7
|
+
output_path = resolve_output_path(output_name)
|
|
8
|
+
base = str(OUTPUT_DIR)
|
|
9
|
+
print(f"\n 合成视频 -> {output_path}")
|
|
10
|
+
|
|
11
|
+
temp_files = []
|
|
12
|
+
|
|
13
|
+
for scene in scenes:
|
|
14
|
+
material = scene.get("material_file")
|
|
15
|
+
audio = scene.get("audio_file")
|
|
16
|
+
dur = scene.get("actual_duration_sec", scene.get("duration_sec", 10))
|
|
17
|
+
narration = scene.get("narration", "")
|
|
18
|
+
idx_str = f"{scene['index']:02d}"
|
|
19
|
+
|
|
20
|
+
temp_output = os.path.join(base, f"{session_id}_scene_{idx_str}.mp4")
|
|
21
|
+
temp_files.append(temp_output)
|
|
22
|
+
|
|
23
|
+
if os.path.exists(temp_output) and os.path.getsize(temp_output) > 0:
|
|
24
|
+
print(f" 分镜 {scene['index']}: [缓存命中] 已渲染 ({dur:.1f}s)")
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
cmd = ["ffmpeg", "-y"]
|
|
28
|
+
|
|
29
|
+
material_dur = _get_video_duration(material) if material else 0
|
|
30
|
+
need_loop = material_dur > 0 and material_dur < dur
|
|
31
|
+
if need_loop:
|
|
32
|
+
cmd += ["-stream_loop", "-1"]
|
|
33
|
+
|
|
34
|
+
if material and os.path.exists(material):
|
|
35
|
+
cmd += ["-i", material]
|
|
36
|
+
|
|
37
|
+
has_audio = audio and os.path.exists(audio)
|
|
38
|
+
if has_audio:
|
|
39
|
+
cmd += ["-i", audio]
|
|
40
|
+
|
|
41
|
+
filters = []
|
|
42
|
+
|
|
43
|
+
if material and os.path.exists(material):
|
|
44
|
+
filters.append(
|
|
45
|
+
f"[0:v]trim=0:{dur},setpts=PTS-STARTPTS,"
|
|
46
|
+
f"scale=1920:1080:force_original_aspect_ratio=decrease,"
|
|
47
|
+
f"pad=1920:1080:(ow-iw)/2:(oh-ih)/2,"
|
|
48
|
+
f"fps=30"
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
filters.append(f"color=c=black:s=1920x1080:d={dur}:r=30")
|
|
52
|
+
|
|
53
|
+
timed_segments = _segment_by_speech(narration, dur)
|
|
54
|
+
font_size = 36
|
|
55
|
+
y_pos = "h-text_h-80"
|
|
56
|
+
|
|
57
|
+
fontfile = f"fontfile='{FONT_PATH}':" if FONT_PATH else ""
|
|
58
|
+
|
|
59
|
+
for seg in timed_segments:
|
|
60
|
+
escaped_text = _escape_drawtext(seg["text"])
|
|
61
|
+
filters.append(
|
|
62
|
+
f"drawtext={fontfile}text='{escaped_text}':"
|
|
63
|
+
f"fontsize={font_size}:fontcolor=white:"
|
|
64
|
+
f"x=(w-text_w)/2:y={y_pos}:"
|
|
65
|
+
f"bordercolor=black@0.6:borderw=2:"
|
|
66
|
+
f"enable='between(t,{seg['start']},{seg['end']})'"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
vf_chain = ",".join(filters) + "[vout]"
|
|
70
|
+
|
|
71
|
+
if has_audio:
|
|
72
|
+
filter_complex = f"[1:a]atrim=0:{dur},afade=t=out:st={max(0, dur - 0.5)}:d=0.5[aout]"
|
|
73
|
+
full_filter = f"{filter_complex};{vf_chain}"
|
|
74
|
+
cmd += ["-filter_complex", full_filter,
|
|
75
|
+
"-map", "[vout]", "-map", "[aout]"]
|
|
76
|
+
else:
|
|
77
|
+
cmd += ["-filter_complex", vf_chain,
|
|
78
|
+
"-map", "[vout]"]
|
|
79
|
+
|
|
80
|
+
cmd += ["-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
|
81
|
+
"-pix_fmt", "yuv420p",
|
|
82
|
+
"-t", str(dur)]
|
|
83
|
+
|
|
84
|
+
if has_audio:
|
|
85
|
+
cmd += ["-c:a", "aac", "-b:a", "128k"]
|
|
86
|
+
|
|
87
|
+
cmd.append(temp_output)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
subprocess.run(cmd, capture_output=True, check=True)
|
|
91
|
+
print(f" 分镜 {scene['index']}: 渲染完成 ({dur:.1f}s)" +
|
|
92
|
+
(" [循环素材]" if need_loop else ""))
|
|
93
|
+
except subprocess.CalledProcessError as e:
|
|
94
|
+
stderr = e.stderr.decode("utf-8", errors="replace")
|
|
95
|
+
print(f" 分镜 {scene['index']}: 渲染失败")
|
|
96
|
+
print(f" {stderr[-300:]}")
|
|
97
|
+
if os.path.exists(temp_output):
|
|
98
|
+
os.remove(temp_output)
|
|
99
|
+
temp_files.remove(temp_output)
|
|
100
|
+
|
|
101
|
+
if not temp_files:
|
|
102
|
+
raise RuntimeError("没有成功渲染的分镜,无法合成")
|
|
103
|
+
|
|
104
|
+
concat_list = os.path.join(base, f"{session_id}_concat_list.txt")
|
|
105
|
+
with open(concat_list, "w") as f:
|
|
106
|
+
for tf in temp_files:
|
|
107
|
+
f.write(f"file '{tf}'\n")
|
|
108
|
+
|
|
109
|
+
out_dir = os.path.dirname(output_path)
|
|
110
|
+
if out_dir:
|
|
111
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
112
|
+
|
|
113
|
+
concat_cmd = [
|
|
114
|
+
"ffmpeg", "-y",
|
|
115
|
+
"-f", "concat", "-safe", "0",
|
|
116
|
+
"-i", concat_list,
|
|
117
|
+
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
|
118
|
+
"-c:a", "aac", "-b:a", "128k",
|
|
119
|
+
"-pix_fmt", "yuv420p",
|
|
120
|
+
"-movflags", "+faststart",
|
|
121
|
+
output_path
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
result = subprocess.run(concat_cmd, capture_output=True)
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
stderr = result.stderr.decode("utf-8", errors="replace")
|
|
127
|
+
raise RuntimeError(f"视频拼接失败:\n{stderr[-500:]}")
|
|
128
|
+
|
|
129
|
+
print(f"\n 成品输出: {output_path}")
|
|
130
|
+
return output_path
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_video_duration(filepath: str) -> float:
|
|
134
|
+
if not filepath or not os.path.exists(filepath):
|
|
135
|
+
return 0
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
138
|
+
"-of", "default=noprint_wrappers=1:nokey=1", filepath],
|
|
139
|
+
capture_output=True, text=True
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
return float(result.stdout.strip())
|
|
143
|
+
except ValueError:
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _segment_by_speech(text: str, total_duration: float) -> list:
|
|
148
|
+
import re
|
|
149
|
+
parts = re.split(r'([,。!?,!\n])', text)
|
|
150
|
+
|
|
151
|
+
segments = []
|
|
152
|
+
current = ""
|
|
153
|
+
for part in parts:
|
|
154
|
+
if part in ",。!?,!\n":
|
|
155
|
+
current += part
|
|
156
|
+
if current.strip():
|
|
157
|
+
segments.append(current.strip())
|
|
158
|
+
current = ""
|
|
159
|
+
else:
|
|
160
|
+
current += part
|
|
161
|
+
if current.strip():
|
|
162
|
+
segments.append(current.strip())
|
|
163
|
+
|
|
164
|
+
if not segments:
|
|
165
|
+
segments = [text]
|
|
166
|
+
|
|
167
|
+
char_counts = [len(s) for s in segments]
|
|
168
|
+
total_chars = sum(char_counts) or 1
|
|
169
|
+
|
|
170
|
+
timed = []
|
|
171
|
+
elapsed = 0.0
|
|
172
|
+
for s in segments:
|
|
173
|
+
seg_dur = (len(s) / total_chars) * total_duration
|
|
174
|
+
timed.append({
|
|
175
|
+
"text": s,
|
|
176
|
+
"start": round(elapsed, 1),
|
|
177
|
+
"end": round(elapsed + seg_dur, 1),
|
|
178
|
+
})
|
|
179
|
+
elapsed += seg_dur
|
|
180
|
+
|
|
181
|
+
return timed
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _escape_drawtext(text: str) -> str:
|
|
185
|
+
text = text.replace("'", "'\\\\\\''")
|
|
186
|
+
text = text.replace(":", "\\:")
|
|
187
|
+
text = text.replace("%", "\\\\%")
|
|
188
|
+
return text
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import requests
|
|
3
|
+
from fible.config import get_pexels_api_key, OUTPUT_DIR
|
|
4
|
+
|
|
5
|
+
PEXELS_VIDEO_URL = "https://api.pexels.com/videos/search"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _download_video(url: str, save_path: str):
|
|
9
|
+
headers = {"User-Agent": "Mozilla/5.0"}
|
|
10
|
+
resp = requests.get(url, headers=headers, stream=True, timeout=60)
|
|
11
|
+
resp.raise_for_status()
|
|
12
|
+
with open(save_path, "wb") as f:
|
|
13
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
14
|
+
f.write(chunk)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def search_videos(keywords: list, per_page: int = 5) -> list:
|
|
18
|
+
api_key = get_pexels_api_key()
|
|
19
|
+
headers = {"Authorization": api_key}
|
|
20
|
+
|
|
21
|
+
all_videos = []
|
|
22
|
+
for kw in keywords[:3]:
|
|
23
|
+
params = {"query": kw, "per_page": per_page, "orientation": "landscape"}
|
|
24
|
+
resp = requests.get(PEXELS_VIDEO_URL, headers=headers, params=params, timeout=15)
|
|
25
|
+
resp.raise_for_status()
|
|
26
|
+
videos = resp.json().get("videos", [])
|
|
27
|
+
all_videos.extend(videos)
|
|
28
|
+
if len(all_videos) >= per_page:
|
|
29
|
+
break
|
|
30
|
+
|
|
31
|
+
return all_videos
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pick_best_clip(videos: list, target_duration: float) -> dict:
|
|
35
|
+
best = None
|
|
36
|
+
best_score = -1
|
|
37
|
+
|
|
38
|
+
for video in videos:
|
|
39
|
+
for vf in video.get("video_files", []):
|
|
40
|
+
if vf.get("width", 0) < 1280:
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
duration = vf.get("duration", 0) or 15
|
|
44
|
+
dur_match = 1 - min(abs(duration - target_duration) / target_duration, 1)
|
|
45
|
+
quality = video.get("user", {}).get("name", "") != ""
|
|
46
|
+
score = dur_match * 0.7 + (0.3 if quality else 0)
|
|
47
|
+
|
|
48
|
+
if score > best_score:
|
|
49
|
+
best_score = score
|
|
50
|
+
best = vf
|
|
51
|
+
|
|
52
|
+
if best is None and videos:
|
|
53
|
+
best = videos[0].get("video_files", [{}])[0]
|
|
54
|
+
|
|
55
|
+
return best
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def download_materials(scenes: list, session_id: str) -> list:
|
|
59
|
+
base = str(OUTPUT_DIR)
|
|
60
|
+
for scene in scenes:
|
|
61
|
+
keywords = scene.get("keywords_en", [])
|
|
62
|
+
target_dur = scene.get("actual_duration_sec", scene.get("duration_sec", 10))
|
|
63
|
+
idx_str = f"{scene['index']:02d}"
|
|
64
|
+
|
|
65
|
+
save_path = os.path.join(base, f"{session_id}_material_{idx_str}.mp4")
|
|
66
|
+
if os.path.exists(save_path) and os.path.getsize(save_path) > 0:
|
|
67
|
+
scene["material_file"] = save_path
|
|
68
|
+
print(f" 分镜 {scene['index']}: [缓存命中] 素材已存在")
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
if not keywords:
|
|
72
|
+
keywords = ["nature", "landscape"]
|
|
73
|
+
|
|
74
|
+
videos = search_videos(keywords)
|
|
75
|
+
clip = pick_best_clip(videos, target_dur)
|
|
76
|
+
|
|
77
|
+
if clip:
|
|
78
|
+
url = clip.get("link")
|
|
79
|
+
if not url:
|
|
80
|
+
print(f" 分镜 {scene['index']}: 未找到可用素材链接")
|
|
81
|
+
scene["material_file"] = None
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
print(f" 分镜 {scene['index']}: 下载素材 (关键词: {keywords[0]})...")
|
|
86
|
+
_download_video(url, save_path)
|
|
87
|
+
scene["material_file"] = save_path
|
|
88
|
+
except Exception as e:
|
|
89
|
+
print(f" 分镜 {scene['index']}: 下载失败 - {e}")
|
|
90
|
+
scene["material_file"] = None
|
|
91
|
+
else:
|
|
92
|
+
print(f" 分镜 {scene['index']}: 未搜索到素材")
|
|
93
|
+
scene["material_file"] = None
|
|
94
|
+
|
|
95
|
+
return scenes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from openai import OpenAI
|
|
4
|
+
from fible.config import get_deepseek_api_key, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, text_hash, save_json, load_json, CACHE_DIR
|
|
5
|
+
|
|
6
|
+
SYSTEM_PROMPT = """你是一个专业的视频分镜师。根据用户提供的文案,将其拆分为多个分镜场景。
|
|
7
|
+
|
|
8
|
+
要求:
|
|
9
|
+
1. 每个分镜的旁白长度控制在 8-15 秒(约 40-80 个汉字)
|
|
10
|
+
2. 画面描述要具体、有画面感,适合搜索视频素材
|
|
11
|
+
3. 提供中英文搜索关键词,英文关键词用于 Pexels/Pixabay 搜素视频,中文用于备用
|
|
12
|
+
4. 相邻分镜的画面风格可以不同,保持视觉节奏感
|
|
13
|
+
5. 返回严格的 JSON 格式,不要包含任何其他文字
|
|
14
|
+
|
|
15
|
+
输出 JSON 格式:
|
|
16
|
+
{
|
|
17
|
+
"title": "视频标题",
|
|
18
|
+
"scenes": [
|
|
19
|
+
{
|
|
20
|
+
"index": 1,
|
|
21
|
+
"narration": "旁白文本",
|
|
22
|
+
"visual_desc": "画面描述(中文)",
|
|
23
|
+
"keywords_en": ["keyword1", "keyword2", "keyword3"],
|
|
24
|
+
"keywords_zh": ["关键词1", "关键词2", "关键词3"],
|
|
25
|
+
"duration_sec": 10
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
duration_sec 根据 narration 文本长度合理估算,约每 4 个汉字 1 秒。"""
|
|
31
|
+
|
|
32
|
+
CACHE_FILE_TEMPLATE = "segment_{hash}.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def segment_text(text: str, force: bool = False) -> dict:
|
|
36
|
+
h = text_hash(text)
|
|
37
|
+
cache_file = CACHE_DIR / CACHE_FILE_TEMPLATE.format(hash=h)
|
|
38
|
+
|
|
39
|
+
if not force:
|
|
40
|
+
cached = load_json(cache_file)
|
|
41
|
+
if cached and "scenes" in cached:
|
|
42
|
+
print(f" [缓存命中] 使用已保存的分镜结果 ({cache_file.name})")
|
|
43
|
+
return cached
|
|
44
|
+
|
|
45
|
+
print(" 调用 DeepSeek API 分析分镜...")
|
|
46
|
+
client = OpenAI(
|
|
47
|
+
api_key=get_deepseek_api_key(),
|
|
48
|
+
base_url=DEEPSEEK_BASE_URL,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
response = client.chat.completions.create(
|
|
52
|
+
model=DEEPSEEK_MODEL,
|
|
53
|
+
messages=[
|
|
54
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
55
|
+
{"role": "user", "content": f"请为以下文案设计分镜:\n\n{text}"},
|
|
56
|
+
],
|
|
57
|
+
temperature=0.7,
|
|
58
|
+
max_tokens=4096,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
content = response.choices[0].message.content.strip()
|
|
62
|
+
|
|
63
|
+
json_match = re.search(r'```(?:json)?\s*(.*?)\s*```', content, re.DOTALL)
|
|
64
|
+
if json_match:
|
|
65
|
+
content = json_match.group(1)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
script = json.loads(content)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
raise ValueError(f"DeepSeek 返回的分镜脚本无法解析为 JSON:\n{content}")
|
|
71
|
+
|
|
72
|
+
if "scenes" not in script:
|
|
73
|
+
raise ValueError("分镜脚本缺少 'scenes' 字段")
|
|
74
|
+
|
|
75
|
+
seen_indices = set()
|
|
76
|
+
for scene in script["scenes"]:
|
|
77
|
+
required = ["index", "narration", "keywords_en", "duration_sec"]
|
|
78
|
+
for field in required:
|
|
79
|
+
if field not in scene:
|
|
80
|
+
raise ValueError(f"分镜 {scene.get('index', '?')} 缺少字段 '{field}'")
|
|
81
|
+
if scene["index"] in seen_indices:
|
|
82
|
+
raise ValueError(f"分镜 index={scene['index']} 重复")
|
|
83
|
+
seen_indices.add(scene["index"])
|
|
84
|
+
|
|
85
|
+
save_json(script, cache_file)
|
|
86
|
+
return script
|
fible/pipeline/tts.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import asyncio
|
|
3
|
+
import subprocess
|
|
4
|
+
import edge_tts
|
|
5
|
+
from fible.config import OUTPUT_DIR
|
|
6
|
+
|
|
7
|
+
VOICES = {
|
|
8
|
+
"zh-CN-XiaoxiaoNeural": "晓晓(女,温柔)",
|
|
9
|
+
"zh-CN-YunxiNeural": "云希(男,温暖)",
|
|
10
|
+
"zh-CN-YunjianNeural": "云健(男,沉稳)",
|
|
11
|
+
"zh-CN-XiaoyiNeural": "晓伊(女,活泼)",
|
|
12
|
+
"zh-CN-YunyangNeural": "云扬(男,新闻感)",
|
|
13
|
+
"zh-CN-XiaochenNeural": "晓辰(女,自然)",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
DEFAULT_VOICE = "zh-CN-YunyangNeural"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def list_voices():
|
|
20
|
+
for k, v in VOICES.items():
|
|
21
|
+
print(f" {k} — {v}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _tts_single(text: str, voice: str, output_file: str, rate: str = "+0%"):
|
|
25
|
+
communicate = edge_tts.Communicate(text, voice, rate=rate)
|
|
26
|
+
await communicate.save(output_file)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_audio_duration(filepath: str) -> float:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
32
|
+
"-of", "default=noprint_wrappers=1:nokey=1", filepath],
|
|
33
|
+
capture_output=True, text=True
|
|
34
|
+
)
|
|
35
|
+
try:
|
|
36
|
+
return float(result.stdout.strip())
|
|
37
|
+
except ValueError:
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_all_speeches(scenes: list, session_id: str, voice: str = DEFAULT_VOICE) -> list:
|
|
42
|
+
async def generate_one(scene):
|
|
43
|
+
name = f"{session_id}_scene_{scene['index']:02d}"
|
|
44
|
+
path = os.path.join(str(OUTPUT_DIR), f"{name}.mp3")
|
|
45
|
+
|
|
46
|
+
if os.path.exists(path):
|
|
47
|
+
print(f" 分镜 {scene['index']}: [缓存命中] 配音已存在")
|
|
48
|
+
dur = _get_audio_duration(path)
|
|
49
|
+
return {**scene, "audio_file": path, "actual_duration_sec": dur}
|
|
50
|
+
|
|
51
|
+
print(f" 分镜 {scene['index']}: 生成配音...")
|
|
52
|
+
await _tts_single(scene["narration"], voice, path)
|
|
53
|
+
dur = _get_audio_duration(path)
|
|
54
|
+
return {**scene, "audio_file": path, "actual_duration_sec": dur}
|
|
55
|
+
|
|
56
|
+
async def run_all():
|
|
57
|
+
return [await generate_one(s) for s in scenes]
|
|
58
|
+
|
|
59
|
+
return asyncio.run(run_all())
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fible
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 一句话生成视频 — AI 文案 + 配音 + 素材 + 合成的视频创作 CLI
|
|
5
|
+
Author: mx
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: edge-tts>=6.1.0
|
|
9
|
+
Requires-Dist: moviepy>=2.0.0
|
|
10
|
+
Requires-Dist: openai>=1.0.0
|
|
11
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
12
|
+
Requires-Dist: requests>=2.31.0
|
|
13
|
+
Requires-Dist: rich>=13.0.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# AI 视频创作 CLI
|
|
17
|
+
|
|
18
|
+
一段文案 → 一个带配音和字幕的视频。全链路本地化,免费开源。
|
|
19
|
+
|
|
20
|
+
## 管线流程
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
文案.txt → DeepSeek V4 Pro 分镜 → edge-tts 配音 → Pexels 素材匹配 → FFmpeg 合成 → 输出.mp4
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 环境要求
|
|
27
|
+
|
|
28
|
+
- Python 3.10+
|
|
29
|
+
- [uv](https://docs.astral.sh/uv/)(Python 包管理器,自动管理虚拟环境)
|
|
30
|
+
- FFmpeg(macOS: `brew install ffmpeg`)
|
|
31
|
+
- 两个 API Key:
|
|
32
|
+
- [DeepSeek API Key](https://platform.deepseek.com/) — 用于文案分镜
|
|
33
|
+
- [Pexels API Key](https://www.pexels.com/api/) — 用于搜索视频素材(免费注册)
|
|
34
|
+
|
|
35
|
+
## 安装
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# 1. 安装 uv(如已安装可跳过)
|
|
39
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
40
|
+
source $HOME/.local/bin/env
|
|
41
|
+
|
|
42
|
+
# 2. 进入项目目录
|
|
43
|
+
cd video-creator
|
|
44
|
+
|
|
45
|
+
# 3. 一键同步依赖 + 创建虚拟环境
|
|
46
|
+
uv sync
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 配置
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# 复制配置模板
|
|
53
|
+
cp .env.example .env
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
编辑 `.env` 文件,填入你的 API Key:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
|
|
60
|
+
PEXELS_API_KEY=xxxxxxxxxxxxxxxx
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 使用
|
|
64
|
+
|
|
65
|
+
### 基本用法
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# 从文案文件生成视频
|
|
69
|
+
uv run python cli.py -i 文案.txt
|
|
70
|
+
|
|
71
|
+
# 指定输出文件名和音色
|
|
72
|
+
uv run python cli.py -i 文案.txt -o 我的视频.mp4 -v zh-CN-YunyangNeural
|
|
73
|
+
|
|
74
|
+
# 仅配音+字幕,不使用视频素材
|
|
75
|
+
uv run python cli.py -i 文案.txt --no-material
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 查看可用音色
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run python cli.py --list-voices
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
输出示例:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
zh-CN-XiaoxiaoNeural — 晓晓(女,温柔)
|
|
88
|
+
zh-CN-YunxiNeural — 云希(男,温暖)
|
|
89
|
+
zh-CN-YunjianNeural — 云健(男,沉稳)
|
|
90
|
+
zh-CN-XiaoyiNeural — 晓伊(女,活泼)
|
|
91
|
+
zh-CN-YunyangNeural — 云扬(男,新闻感)
|
|
92
|
+
zh-CN-XiaochenNeural — 晓辰(女,自然)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
不指定音色时默认使用 `YunyangNeural`(云扬,适合知识类内容)。
|
|
96
|
+
|
|
97
|
+
### 运行示例
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# 使用内置测试文案
|
|
101
|
+
uv run python cli.py -i test_story.txt -o output/test.mp4
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
执行过程输出:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
AI 视频创作
|
|
108
|
+
输入: test_story.txt
|
|
109
|
+
输出: output/test.mp4
|
|
110
|
+
音色: zh-CN-YunyangNeural
|
|
111
|
+
|
|
112
|
+
▸ Step 1/4: AI 分镜分析
|
|
113
|
+
┌──────┬────────────────┬──────┬────────────┐
|
|
114
|
+
│ 序号 │ 旁白摘要 │ 时长 │ 关键词 │
|
|
115
|
+
├──────┼────────────────┼──────┼────────────┤
|
|
116
|
+
│ 1 │ 人工智能正以... │ 10s │ AI, daily. │
|
|
117
|
+
│ 2 │ 在医疗领域... │ 9s │ medical,.. │
|
|
118
|
+
│ 3 │ 然而技术进... │ 10s │ privacy,.. │
|
|
119
|
+
│ 4 │ 未来随着技... │ 8s │ future, AI │
|
|
120
|
+
└──────┴────────────────┴──────┴────────────┘
|
|
121
|
+
|
|
122
|
+
▸ Step 2/4: 生成配音
|
|
123
|
+
✓ 完成 4 段配音
|
|
124
|
+
|
|
125
|
+
▸ Step 3/4: 匹配素材
|
|
126
|
+
搜索并下载素材...
|
|
127
|
+
分镜 1: material_01.mp4
|
|
128
|
+
分镜 2: material_02.mp4
|
|
129
|
+
分镜 3: material_03.mp4
|
|
130
|
+
分镜 4: material_04.mp4
|
|
131
|
+
|
|
132
|
+
▸ Step 4/4: 合成视频
|
|
133
|
+
分镜 1: 渲染完成 (10.2s)
|
|
134
|
+
分镜 2: 渲染完成 (9.5s)
|
|
135
|
+
分镜 3: 渲染完成 (10.0s)
|
|
136
|
+
分镜 4: 渲染完成 (8.3s)
|
|
137
|
+
|
|
138
|
+
成品输出: /path/to/output/test.mp4
|
|
139
|
+
|
|
140
|
+
✓ 视频创作完成!
|
|
141
|
+
文件: /path/to/output/test.mp4
|
|
142
|
+
耗时: 45.3s
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## 产出质量
|
|
146
|
+
|
|
147
|
+
- 分辨率:1920×1080(1080p)
|
|
148
|
+
- 帧率:30fps
|
|
149
|
+
- 编码:H.264 + AAC
|
|
150
|
+
- 字幕:硬字幕,白字黑边,居中显示
|
|
151
|
+
|
|
152
|
+
## 项目结构
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
video-creator/
|
|
156
|
+
├── cli.py # 命令行入口
|
|
157
|
+
├── config.py # 配置管理(API Key、输出路径)
|
|
158
|
+
├── pyproject.toml # 项目元数据与依赖声明
|
|
159
|
+
├── uv.lock # 依赖锁定文件
|
|
160
|
+
├── pipeline/
|
|
161
|
+
│ ├── __init__.py
|
|
162
|
+
│ ├── segmenter.py # DeepSeek V4 Pro 文案分镜
|
|
163
|
+
│ ├── tts.py # edge-tts 语音合成
|
|
164
|
+
│ ├── matcher.py # Pexels 素材搜索与下载
|
|
165
|
+
│ └── composer.py # FFmpeg 视频合成
|
|
166
|
+
├── output/ # 生成文件存放目录
|
|
167
|
+
├── .env.example # API Key 配置模板
|
|
168
|
+
└── test_story.txt # 测试文案
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## 模块说明
|
|
172
|
+
|
|
173
|
+
### segmenter.py — AI 分镜
|
|
174
|
+
|
|
175
|
+
调用 DeepSeek API 将输入文案拆分为分镜脚本。
|
|
176
|
+
|
|
177
|
+
- **模型:** `deepseek-chat`
|
|
178
|
+
- **输入:** 原始文案文本
|
|
179
|
+
- **输出:** 结构化 JSON,包含每个分镜的旁白、画面描述、中英文关键词、预估时长
|
|
180
|
+
|
|
181
|
+
DeepSeek 的系统提示词会引导模型:
|
|
182
|
+
- 将文案拆分为 8-15 秒/个的分镜
|
|
183
|
+
- 提供具体有画面感的视觉描述
|
|
184
|
+
- 生成英文关键词用于 Pexels 素材搜索
|
|
185
|
+
- 估算每个分镜的时长(约 4 字/秒)
|
|
186
|
+
|
|
187
|
+
### tts.py — 语音合成
|
|
188
|
+
|
|
189
|
+
使用微软 edge-tts 将旁白文本转为语音。
|
|
190
|
+
|
|
191
|
+
- **方案:** edge-tts(免费,无调用限制)
|
|
192
|
+
- **音色:** 6 种中文神经网络音色可选
|
|
193
|
+
- **格式:** MP3
|
|
194
|
+
- **输出:** 自动探测音频实际时长,回填到分镜数据中
|
|
195
|
+
|
|
196
|
+
### matcher.py — 素材匹配
|
|
197
|
+
|
|
198
|
+
通过 Pexels API 搜索并下载视频素材。
|
|
199
|
+
|
|
200
|
+
- **API:** Pexels Videos Search(免费,每月 200 次请求)
|
|
201
|
+
- **搜索策略:** 按英文关键词逐个搜索,取前 5 个结果
|
|
202
|
+
- **筛选逻辑:**
|
|
203
|
+
1. 过滤 720p 以下低分辨率素材
|
|
204
|
+
2. 按"时长匹配度(70%)+ 创作者可信度(30%)"评分
|
|
205
|
+
3. 选最高分的素材片段下载
|
|
206
|
+
- **下载格式:** 原始分辨率 MP4
|
|
207
|
+
|
|
208
|
+
### composer.py — 视频合成
|
|
209
|
+
|
|
210
|
+
使用 FFmpeg 将素材、配音、字幕合成为最终视频。
|
|
211
|
+
|
|
212
|
+
- **处理步骤:**
|
|
213
|
+
1. 素材裁剪到分镜时长 → 缩放居中为 1920×1080
|
|
214
|
+
2. 叠加配音音频(自动截取对应时长)
|
|
215
|
+
3. 添加硬字幕(drawtext 滤镜,白字黑边)
|
|
216
|
+
4. 所有分镜拼接为完整视频
|
|
217
|
+
- **字幕处理:** 长文本自动换行,最多显示 2-3 行,逐行叠加
|
|
218
|
+
- **中文字体:** 使用 macOS 系统字体 `STHeiti Medium`
|
|
219
|
+
|
|
220
|
+
## 故障排查
|
|
221
|
+
|
|
222
|
+
### 分镜阶段报错
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
ValueError: DeepSeek 返回的分镜脚本无法解析为 JSON
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
原因:DeepSeek 返回格式异常。重试即可,模型偶有输出不稳定。
|
|
229
|
+
|
|
230
|
+
### 配音阶段无声音或报错
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
# 手动测试 edge-tts 是否正常
|
|
234
|
+
uv run python -c "
|
|
235
|
+
import asyncio, edge_tts
|
|
236
|
+
async def test():
|
|
237
|
+
c = edge_tts.Communicate('测试语音', 'zh-CN-YunyangNeural')
|
|
238
|
+
await c.save('test.mp3')
|
|
239
|
+
print('OK')
|
|
240
|
+
asyncio.run(test())
|
|
241
|
+
"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
如果失败,检查网络连接(edge-tts 需访问微软服务)。
|
|
245
|
+
|
|
246
|
+
### 素材搜索返回空
|
|
247
|
+
|
|
248
|
+
- 确认 Pexels API Key 正确配置在 `.env` 中
|
|
249
|
+
- Pexels 免费账户每月限额 200 次请求,超出会返回 429
|
|
250
|
+
- 某些中文概念的英文关键词可能匹配度低,可尝试使用 `--no-material` 跳过素材
|
|
251
|
+
|
|
252
|
+
### 合成阶段中文乱码
|
|
253
|
+
|
|
254
|
+
确认系统中存在中文字体:
|
|
255
|
+
```bash
|
|
256
|
+
ls /System/Library/Fonts/STHeiti\ Medium.ttc
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Linux 系统需修改 `composer.py` 中的 `FONT_PATH`。
|
|
260
|
+
|
|
261
|
+
### FFmpeg not found
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
# macOS
|
|
265
|
+
brew install ffmpeg
|
|
266
|
+
|
|
267
|
+
# Ubuntu/Debian
|
|
268
|
+
sudo apt install ffmpeg
|
|
269
|
+
|
|
270
|
+
# Windows
|
|
271
|
+
# 下载 https://ffmpeg.org/download.html,解压后加入 PATH
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## 许可证
|
|
275
|
+
|
|
276
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
fible/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
2
|
+
fible/__main__.py,sha256=IWwhw6-HFlr00jcG6e6A7egfN7PPJ-9QUA45n09rOyw,66
|
|
3
|
+
fible/cli.py,sha256=qFt07kCRt7WStNkv-ddc0tlLXWN_munJV-XPSFTfi-w,7960
|
|
4
|
+
fible/config.py,sha256=5dTywFF0sbWtG7GEcYHfp-lci0MjgTKB_8vOtMJXpW0,2497
|
|
5
|
+
fible/pipeline/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
fible/pipeline/composer.py,sha256=Ddd-oruej3YqGvlGc3zqRs5jugGHh9PM8B6-WwQ7xUA,6085
|
|
7
|
+
fible/pipeline/matcher.py,sha256=Ie_vC2fgRp7dXH986iOUx6L-H2scea2P8Sc7o_GBBgI,3225
|
|
8
|
+
fible/pipeline/segmenter.py,sha256=Cn_7D5UW7AAYbM0syolaLjRZzMexREMIDovd9wSy-Zg,2950
|
|
9
|
+
fible/pipeline/tts.py,sha256=FHHOslDpRPDPaZXqi0NTiXfoM35GZgK5FO9YXPaGJPM,1947
|
|
10
|
+
fible-0.1.0.dist-info/METADATA,sha256=zyEnxaqdRFdYo6vNMGu1jJ4obyzInQLFJxmVmeU0_hc,7599
|
|
11
|
+
fible-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
fible-0.1.0.dist-info/entry_points.txt,sha256=-gheghuNGzy_kYHNMLXIR5FVGTGr3xcM_kjoq-vLinM,41
|
|
13
|
+
fible-0.1.0.dist-info/RECORD,,
|