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 ADDED
@@ -0,0 +1 @@
1
+
fible/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from fible.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fible = fible.cli:main