鉴影 ICE-GL-CA001 969ca660c0 EED-129 · LIB v2.0 · 管线全面升级
铁律合规:
- P0: 移除三处硬编码API密钥 → lib/secrets.py 三层查找
- P0: 统一LIB/engines调用关系(tts复用音色表,composer桥接editor)
- P0: batch支持参数+batch-resume断点续传
- P0: Seed一致性(seed_value/lock_seed字段+gen --lock-seed)
- P0: 跨集资产继承(inherit命令)+A/B对比(compare命令)
- P1: concat逻辑修复/滤镜崩溃修复/FFmpeg路径统一/成本追踪
- P2: 状态判断修复/CLI --help+--version+模糊匹配
- 新增: 65条命令(原17条)覆盖全链路
- 新增: deps/info/note/history/report/flow/director等
- 语法OK 零重复函数 零密钥泄露
2026-07-10 14:49:17 +08:00

135 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LIB · TTS 配音模块 + 自动字幕生成
# D180 · 2026-07-10
"""Edge-TTS 配音引擎 · 多角色音色 · 自动SRT字幕"""
import asyncio, edge_tts, os, json, shutil
# 角色音色表 · 从 engines/tts-engine.py 加载(单一真相源)
try:
import sys, os as _os
_engine_dir = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), "engines")
if _engine_dir not in sys.path: sys.path.insert(0, _engine_dir)
from tts_engine import DEFAULT_VOICES as _ENGINE_VOICES
VOICES = {name: cfg["voice"] for name, cfg in _ENGINE_VOICES.items()}
except Exception:
VOICES = {
"苏白": "zh-CN-XiaoxiaoNeural",
"诸葛风": "zh-CN-YunxiNeural",
"萧灵汐": "zh-CN-XiaoyiNeural",
"王执事": "zh-CN-YunyangNeural",
"林昊": "zh-CN-YunxiNeural",
"旁白": "zh-CN-YunyangNeural",
"默认女": "zh-CN-XiaoxiaoNeural",
"默认男": "zh-CN-YunxiNeural",
}
FFMPEG = shutil.which("ffmpeg") or os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe")
def tts(text, output_path, voice="zh-CN-YunxiNeural", rate="+0%"):
"""生成语音 mp3返回时间戳用于字幕"""
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
words = []
async def _run():
comm = edge_tts.Communicate(text, voice, rate=rate)
sub = edge_tts.SubMaker()
with open(output_path, "wb") as f:
async for chunk in comm.stream():
if chunk["type"] == "audio":
f.write(chunk["data"])
elif chunk["type"] == "SentenceBoundary":
words.append({
"text": chunk["text"],
"start": chunk["offset"] / 10000000,
"end": (chunk["offset"] + chunk["duration"]) / 10000000
})
asyncio.run(_run())
return {"path": output_path, "words": words} if os.path.exists(output_path) else None
def tts_for_character(text, character, output_path, rate="+0%"):
"""按角色名查音色生成"""
voice = VOICES.get(character, "zh-CN-YunxiNeural")
return tts(text, output_path, voice, rate)
def words_to_srt(words, output_path, offset=0):
"""句时间戳 → SRT 字幕文件"""
if not words:
return None
lines = []
for i, w in enumerate(words):
def fmt(t):
ms = int((offset + t) * 1000)
h = ms // 3600000; ms %= 3600000
m = ms // 60000; ms %= 60000
s = ms // 1000; ms %= 1000
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
lines.append(f"{i+1}")
lines.append(f"{fmt(w['start'])} --> {fmt(w['end'])}")
lines.append(w["text"])
lines.append("")
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
return output_path
def burn_subtitle(video_path, srt_path, output_path):
"""烧录字幕到视频"""
if not os.path.exists(FFMPEG):
return f"ffmpeg not found at {FFMPEG}"
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
import subprocess
srt_esc = srt_path.replace("\\", "/").replace(":", "\\:")
r = subprocess.run([
FFMPEG, "-y",
"-i", video_path,
"-vf", f"subtitles='{srt_esc}':force_style='Fontsize=24,Alignment=2,BorderStyle=3,Outline=1,Shadow=1'",
"-c:a", "copy",
output_path
], capture_output=True, text=True)
return output_path if r.returncode == 0 else r.stderr[:300]
def mix_audio_video(video_path, audio_path, output_path, video_vol=0.3):
"""FFmpeg 合成视频+音频"""
if not os.path.exists(FFMPEG):
return f"ffmpeg not found at {FFMPEG}"
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
import subprocess
r = subprocess.run([
FFMPEG, "-y",
"-i", video_path,
"-i", audio_path,
"-filter_complex", f"[0:a]volume={video_vol}[va];[1:a]volume=1.0[aa];[va][aa]amix=inputs=2:duration=first",
"-c:v", "copy",
output_path
], capture_output=True, text=True)
return output_path if r.returncode == 0 else r.stderr
def add_subtitle(video_path, subtitle_text, output_path):
"""FFmpeg 添加字幕"""
if not os.path.exists(FFMPEG):
return f"ffmpeg not found at {FFMPEG}"
import subprocess
r = subprocess.run([
FFMPEG, "-y",
"-i", video_path,
"-vf", f"drawtext=text='{subtitle_text}':fontsize=28:fontcolor=white:borderw=2:bordercolor=black:x=(w-text_w)/2:y=h-120",
"-c:a", "copy",
output_path
], capture_output=True, text=True)
return output_path if r.returncode == 0 else r.stderr
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python tts.py tts <text> <output> [voice]")
print(" python tts.py char <text> <character> <output>")
print(" python tts.py mix <video> <audio> <output>")
print(f"\nVoices: {list(VOICES.keys())}")
sys.exit(1)
cmd = sys.argv[1]
if cmd == "tts":
voice = sys.argv[4] if len(sys.argv) > 4 else "zh-CN-YunxiNeural"
print(tts(sys.argv[2], sys.argv[3], voice) or "FAILED")
elif cmd == "char":
print(tts_for_character(sys.argv[2], sys.argv[3], sys.argv[4]) or "FAILED")
elif cmd == "mix":
print(mix_audio_video(sys.argv[2], sys.argv[3], sys.argv[4]) or "FAILED")