From 1eaa2b532ffa89092314ef5c0e54ed3df065f7e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Tue, 23 Jun 2026 23:18:01 +0800 Subject: [PATCH] =?UTF-8?q?D144:=20=E9=85=8D=E9=9F=B3+=E5=AD=97=E5=B9=95?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF=E5=BC=80=E5=8F=91=C2=B7Agent=E5=8F=AF?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 engines/tts-engine.py:Edge-TTS配音引擎,支持多角色音色配置 - 新增 engines/subtitle-renderer.py:字幕渲染引擎,SRT→PNG序列 - 更新 ENTRY.hdlp:工具链增加 tts-engine.py 和 subtitle-renderer.py - 依赖:pip install edge-tts pysrt cairosvg Pillow - 路径:video-ai-system/engines/tts-engine.py --- video-ai-system/ENTRY.hdlp | 4 +- video-ai-system/engines/subtitle-renderer.py | 319 +++++++++++++++++++ video-ai-system/engines/tts-engine.py | 311 ++++++++++++++++++ 3 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 video-ai-system/engines/subtitle-renderer.py create mode 100644 video-ai-system/engines/tts-engine.py diff --git a/video-ai-system/ENTRY.hdlp b/video-ai-system/ENTRY.hdlp index 62daa60..f5726e5 100644 --- a/video-ai-system/ENTRY.hdlp +++ b/video-ai-system/ENTRY.hdlp @@ -178,9 +178,11 @@ video-ai-system/ | video-composer.js (FFmpeg拼接) | 自研 | ✅ (溶解过渡·faststart) | | image-api-adapter.js (Seedream生图) | 自研 | ✅ (苏白人设图已生成) | | svg-to-png.py (SVG→PNG渲染) | 自研 | ✅ (D144·CairoSVG·解决FFmpeg无SVG解码器) | +| tts-engine.py (Edge-TTS配音) | MIT | ✅ (D144·中文语音·多角色音色配置) | +| subtitle-renderer.py (字幕渲染) | 自研 | ⚠️ (PNG序列渲染·FFmpeg合成待完善) | | MoviePy v2.x | MIT | ⏳ 待评估 | | WhisperX + Subaligner | MIT | ⏳ | -| Edge-TTS (一期) → GPT-SoVITS MIT (二期) | MIT | ⏳ | +| GPT-SoVITS (二期音色克隆) | MIT | ⏳ | ## D135 八坑炼规矩 · cc-014 diff --git a/video-ai-system/engines/subtitle-renderer.py b/video-ai-system/engines/subtitle-renderer.py new file mode 100644 index 0000000..165cfc7 --- /dev/null +++ b/video-ai-system/engines/subtitle-renderer.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +""" +Subtitle Renderer · 字幕渲染引擎 +============================= +将 SRT 字幕文件渲染为 PNG 序列,再通过 FFmpeg 合成到视频中。 + +依赖: + pip install cairosvg Pillow pysrt + +用法: + # 基本用法(SRT → PNG 序列) + python subtitle-renderer.py --srt input.srt --output-dir ./subtitles-png/ + + # 指定视频尺寸(PNG 宽度匹配视频) + python subtitle-renderer.py --srt input.srt --output-dir ./subtitles-png/ --width 1080 --height 1920 + + # 渲染后直接合成到视频 + python subtitle-renderer.py --srt input.srt --video input.mp4 --output output.mp4 + + # 自定义字幕样式 + python subtitle-renderer.py --srt input.srt --font-size 48 --font-color white --bg-color black --position bottom + + # 作为模块导入 + from subtitle_renderer import render_subtitles + render_subtitles("input.srt", "./subtitles-png/") + +字幕样式配置: + --font-size : 字体大小(默认 36) + --font-color : 字体颜色(默认 white) + --bg-color : 背景色(默认 semi-transparent black) + --position : 位置(top / middle / bottom,默认 bottom) + --margin-bottom : 底部边距(默认 100px) + +路径: + video-ai-system/engines/subtitle-renderer.py +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +try: + import pysrt +except ImportError: + print("[ERROR] 缺少依赖:pysrt") + print("请先安装:pip install pysrt") + sys.exit(1) + +try: + import cairosvg + from PIL import Image, ImageDraw, ImageFont +except ImportError: + print("[ERROR] 缺少依赖:cairosvg Pillow") + print("请先安装:pip install cairosvg Pillow") + sys.exit(1) + + +# 默认字幕样式 +DEFAULT_STYLE = { + "font_size": 36, + "font_color": "white", + "bg_color": "rgba(0, 0, 0, 0.6)", + "position": "bottom", # top / middle / bottom + "margin_bottom": 100, + "margin_horizontal": 60, + "stroke_color": "black", + "stroke_width": 2, + "video_width": 1080, + "video_height": 1920, +} + + +def render_subtitle_png( + text: str, + output_path: str, + width: int = 1080, + height: int = 200, + style: dict = None +) -> bool: + """ + 渲染单条字幕为 PNG(带背景) + + :param text: 字幕文本 + :param output_path: 输出 PNG 路径 + :param width: PNG 宽度(匹配视频宽度) + :param height: PNG 高度 + :param style: 字幕样式字典 + :return: 是否成功 + """ + if style is None: + style = DEFAULT_STYLE + + try: + # 创建透明背景 PNG + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # 字体(使用系统字体) + font_size = style.get("font_size", 36) + try: + font = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", font_size) + except: + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size) + except: + font = ImageFont.load_default() + + # 计算文本尺寸 + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # 居中位置 + x = (width - text_width) // 2 + y = (height - text_height) // 2 + + # 绘制背景(半透明黑底) + bg_padding = 20 + bg_x1 = x - bg_padding + bg_y1 = y - bg_padding + bg_x2 = x + text_width + bg_padding + bg_y2 = y + text_height + bg_padding + draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=(0, 0, 0, 160)) + + # 绘制描边 + stroke_width = style.get("stroke_width", 2) + stroke_color = style.get("stroke_color", "black") + for offset in range(-stroke_width, stroke_width + 1): + draw.text((x + offset, y), text, font=font, fill=stroke_color) + draw.text((x, y + offset), text, font=font, fill=stroke_color) + + # 绘制主文本 + font_color = style.get("font_color", "white") + draw.text((x, y), text, font=font, fill=font_color) + + # 保存 + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + img.save(output_path, "PNG") + + return True + + except Exception as e: + print(f"[ERROR] 渲染字幕失败:{e}") + return False + + +def render_subtitles( + srt_path: str, + output_dir: str, + style: dict = None +) -> dict: + """ + 渲染 SRT 字幕为 PNG 序列 + + :param srt_path: SRT 文件路径 + :param output_dir: 输出目录 + :param style: 字幕样式字典 + :return: {idx: {"png": png_path, "start": start_time, "end": end_time, "text": text}} + """ + if not os.path.isfile(srt_path): + print(f"[ERROR] SRT 文件不存在:{srt_path}") + return {} + + os.makedirs(output_dir, exist_ok=True) + + # 加载 SRT + subs = pysrt.open(srt_path, encoding="utf-8") + print(f"[INFO] 找到 {len(subs)} 条字幕,开始渲染 PNG 序列...") + + results = {} + for sub in subs: + idx = str(sub.index).zfill(4) + text = sub.text.strip() + start_time = sub.start.ordinal # 毫秒 + end_time = sub.end.ordinal + + # 渲染 PNG + png_path = os.path.join(output_dir, f"{idx}.png") + video_width = style.get("video_width", 1080) if style else 1080 + png_height = style.get("font_size", 36) * 3 if style else 108 + + ok = render_subtitle_png(text, png_path, width=video_width, height=png_height, style=style) + if ok: + results[idx] = { + "png": png_path, + "start": start_time, + "end": end_time, + "text": text + } + + print(f"[OK] 字幕 PNG 序列渲染完成:{len(results)}/{len(subs)} 成功") + return results + + +def burn_subtitles_to_video( + video_path: str, + subtitles: dict, + output_path: str, + video_width: int = 1080, + video_height: int = 1920 +) -> bool: + """ + 将 PNG 字幕序列合成到视频中(使用 FFmpeg overlay 滤镜) + + :param video_path: 输入视频路径 + :param subtitles: render_subtitles 返回的字典 + :param output_path: 输出视频路径 + :param video_width: 视频宽度 + :param video_height: 视频高度 + :return: 是否成功 + """ + if not subtitles: + print("[ERROR] 没有字幕数据") + return False + + # 生成 FFmpeg 复杂滤镜表达式 + # 思路:为每个字幕 PNG 创建带有时序的 overlay + # 简化版:使用 subtitles 滤镜(需要 FFmpeg 编译时启用) + # + # 由于本机 FFmpeg 无 subtitles 滤镜,改用 drawtext 方案 + # 如果 drawtext 也不可用,则生成带透明度的 PNG 序列,用 overlay 滤镜 + + print("[INFO] 生成字幕叠加滤镜脚本...") + + # 简化方案:生成所有字幕 PNG 后,用 FFmpeg 的 overlay 滤镜 + # 为每个时间段启用对应的 PNG + # 这需要用 FFmpeg 的 `enable` 表达式 + + filter_complex = [] + input_count = 1 # 第0个输入是视频 + + for idx, sub in subtitles.items(): + png_path = sub["png"] + start_sec = sub["start"] / 1000.0 + end_sec = sub["end"] / 1000.0 + + # 添加 PNG 输入 + filter_complex.append(f"[1:v]scale={video_width}:-1[png{idx}]") + # 简化:直接用一条命令处理所有字幕(实际应该用动态叠加) + + input_count += 1 + + # 实际实现:用一条简单的 FFmpeg 命令测试 + # 这里先输出一个简化版:把第一条字幕叠加上去 + first_png = list(subtitles.values())[0]["png"] + + cmd = ( + f'ffmpeg -i "{video_path}" -i "{first_png}" ' + f'-filter_complex "[0:v][1:v] overlay=0:(H-h) [out]" ' + f'-map "[out]" -c:a copy "{output_path}" -y' + ) + + print(f"[INFO] FFmpeg 命令:{cmd}") + + # 实际应该用更准确的方案,这里先输出架构 + print(f"\n[INFO] 字幕合成需要更复杂的 FFmpeg 滤镜表达式。") + print(f"[INFO] 建议方案:") + print(f" 1. 使用 FFmpeg 的 `drawtext` 滤镜(需要重新编译 FFmpeg)") + print(f" 2. 或使用专业字幕工具(如 Aegisub)生成 ASS,再用 FFmpeg 烧录") + print(f"\n[INFO] 当前已生成字幕 PNG 序列在:{os.path.dirname(first_png)}") + print(f"[INFO] 可手动用 FFmpeg 或视频编辑软件合成。") + + return False + + +def main(): + parser = argparse.ArgumentParser( + description="Subtitle Renderer · 字幕渲染引擎", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python subtitle-renderer.py --srt input.srt --output-dir ./subtitles-png/ + python subtitle-renderer.py --srt input.srt --video input.mp4 --output output.mp4 + python subtitle-renderer.py --srt input.srt --font-size 48 --position bottom +""" + ) + parser.add_argument("--srt", required=True, help="SRT 字幕文件路径") + parser.add_argument("--output-dir", help="PNG 序列输出目录") + parser.add_argument("--video", help="输入视频路径(可选,用于直接合成)") + parser.add_argument("--output", help="输出视频路径(配合 --video 使用)") + parser.add_argument("--font-size", type=int, default=36, help="字体大小") + parser.add_argument("--font-color", default="white", help="字体颜色") + parser.add_argument("--bg-color", default="rgba(0,0,0,0.6)", help="背景色") + parser.add_argument("--position", default="bottom", choices=["top", "middle", "bottom"], help="字幕位置") + parser.add_argument("--video-width", type=int, default=1080, help="视频宽度") + parser.add_argument("--video-height", type=int, default=1920, help="视频高度") + + args = parser.parse_args() + + # 构建样式字典 + style = { + "font_size": args.font_size, + "font_color": args.font_color, + "bg_color": args.bg_color, + "position": args.position, + "video_width": args.video_width, + "video_height": args.video_height, + } + + # 渲染 PNG 序列 + output_dir = args.output_dir or "./subtitles-png/" + subtitles = render_subtitles(args.srt, output_dir, style) + + if not subtitles: + sys.exit(1) + + # 如果指定了视频,则合成 + if args.video and args.output: + burn_subtitles_to_video(args.video, subtitles, args.output, args.video_width, args.video_height) + + print(f"\n[OK] 字幕 PNG 序列已生成:{output_dir}") + print(f"[INFO] 共 {len(subtitles)} 条字幕") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/video-ai-system/engines/tts-engine.py b/video-ai-system/engines/tts-engine.py new file mode 100644 index 0000000..5fc1e61 --- /dev/null +++ b/video-ai-system/engines/tts-engine.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +""" +TTS Engine · Edge-TTS 配音引擎 +================================== +为视频AI系统提供文本转语音能力,支持多角色音色配置。 + +依赖: + pip install edge-tts + +用法: + # 基本用法(默认中文女声) + python tts-engine.py --text "你好世界" --output output.mp3 + + # 指定角色(从配置文件读取音色) + python tts-engine.py --text "未来的天下第一宗!" --character "苏白" --output su-bai.mp3 + + # 指定语音(Edge-TTS 语音名) + python tts-engine.py --text "Hello World" --voice "en-US-JennyNeural" --output hello.mp3 + + # 调整语速/音调/音量 + python tts-engine.py --text "你好" --rate "+20%" --pitch "+5Hz" --volume "+10%" --output output.mp3 + + # 批量生成(从SRT字幕文件) + python tts-engine.py --srt input.srt --output-dir ./audio/ --character "苏白" + + # 作为模块导入 + from tts_engine import generate_speech + generate_speech("你好世界", "output.mp3", voice="zh-CN-XiaoxiaoNeural") + +角色音色配置: + video-ai-system/config/voices.json + +路径: + video-ai-system/engines/tts-engine.py +""" + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path + +try: + import edge_tts +except ImportError: + print("[ERROR] 缺少依赖:edge-tts") + print("请先安装:pip install edge-tts") + sys.exit(1) + + +# 默认角色音色配置 +DEFAULT_VOICES = { + "苏白": { + "voice": "zh-CN-XiaoxiaoNeural", # 阳光少年音 + "rate": "+5%", + "pitch": "+0Hz" + }, + "诸葛风": { + "voice": "zh-CN-YunxiNeural", # 沉稳男声 + "rate": "+0%", + "pitch": "-5Hz" + }, + "萧灵汐": { + "voice": "zh-CN-XiaoyiNeural", # 清冷女声 + "rate": "+0%", + "pitch": "+0Hz" + }, + "王执事": { + "voice": "zh-CN-YunyangNeural", # 中年男声 + "rate": "+0%", + "pitch": "-10Hz" + } +} + + +def load_voice_config(config_path: str = None) -> dict: + """加载角色音色配置文件""" + if config_path and os.path.isfile(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"[WARN] 无法读取配置文件 {config_path}:{e}") + print("[INFO] 使用默认音色配置") + return DEFAULT_VOICES + + +async def generate_speech_async( + text: str, + output_path: str, + voice: str = "zh-CN-XiaoxiaoNeural", + rate: str = "+0%", + pitch: str = "+0Hz", + volume: str = "+0%" +) -> bool: + """ + 异步生成语音(Edge-TTS) + + :param text: 要合成的文本 + :param output_path: 输出音频文件路径 + :param voice: Edge-TTS 语音名 + :param rate: 语速(如 "+20%"、"-10%") + :param pitch: 音调(如 "+5Hz"、"-10Hz") + :param volume: 音量(如 "+10%"、"-5%") + :return: 是否成功 + """ + try: + # 确保输出目录存在 + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + + communicate = edge_tts.Communicate( + text, voice, + rate=rate, pitch=pitch, volume=volume + ) + await communicate.save(output_path) + + # 验证输出文件 + if os.path.isfile(output_path): + file_size = os.path.getsize(output_path) + print(f"[OK] 生成语音:{output_path} ({file_size // 1024} KB)") + print(f" 语音:{voice} | 语速:{rate} | 音调:{pitch}") + return True + else: + print(f"[ERROR] 输出文件未生成:{output_path}") + return False + + except Exception as e: + print(f"[ERROR] 生成语音失败:{e}") + return False + + +def generate_speech( + text: str, + output_path: str, + voice: str = "zh-CN-XiaoxiaoNeural", + rate: str = "+0%", + pitch: str = "+0Hz", + volume: str = "+0%" +) -> bool: + """ + 同步包装器(供外部调用) + + :param text: 要合成的文本 + :param output_path: 输出音频文件路径 + :param voice: Edge-TTS 语音名 + :param rate: 语速 + :param pitch: 音调 + :param volume: 音量 + :return: 是否成功 + """ + return asyncio.run(generate_speech_async(text, output_path, voice, rate, pitch, volume)) + + +def generate_by_character( + text: str, + output_path: str, + character: str, + config: dict = None +) -> bool: + """ + 按角色名生成语音(自动读取音色配置) + + :param text: 要合成的文本 + :param output_path: 输出音频文件路径 + :param character: 角色名(如 "苏白") + :param config: 音色配置字典(可选,默认加载 DEFAULT_VOICES) + :return: 是否成功 + """ + if config is None: + config = load_voice_config() + + if character not in config: + print(f"[WARN] 角色 '{character}' 未配置音色,使用默认音色") + return generate_speech(text, output_path) + + voice_config = config[character] + return generate_speech( + text, output_path, + voice=voice_config.get("voice", "zh-CN-XiaoxiaoNeural"), + rate=voice_config.get("rate", "+0%"), + pitch=voice_config.get("pitch", "+0Hz") + ) + + +def process_srt(srt_path: str, output_dir: str, character: str = None, config: dict = None): + """ + 从SRT字幕文件批量生成语音 + + :param srt_path: SRT 文件路径 + :param output_dir: 输出目录 + :param character: 角色名(所有台词用同一音色) + :param config: 音色配置字典 + """ + if not os.path.isfile(srt_path): + print(f"[ERROR] SRT 文件不存在:{srt_path}") + return False + + os.makedirs(output_dir, exist_ok=True) + + # 简单SRT解析(按空行分割) + with open(srt_path, "r", encoding="utf-8") as f: + content = f.read() + + blocks = content.strip().split("\n\n") + print(f"[INFO] 找到 {len(blocks)} 条字幕,开始生成语音...") + + success = 0 + for block in blocks: + lines = block.strip().split("\n") + if len(lines) < 3: + continue + + idx = lines[0].strip() + # timeine = lines[1].strip() # 暂不使用时序 + text = " ".join(lines[2:]).strip() + + if not text: + continue + + output_path = os.path.join(output_dir, f"{idx.zfill(4)}.mp3") + + if character: + ok = generate_by_character(text, output_path, character, config) + else: + ok = generate_speech(text, output_path) + + if ok: + success += 1 + + print(f"\n[INFO] SRT批量生成完成:{success}/{len(blocks)} 成功") + return success == len(blocks) + + +def list_voices(): + """列出所有可用的Edge-TTS语音""" + print("[INFO] 正在获取可用语音列表...\n") + asyncio.run(_list_voices_async()) + + +async def _list_voices_async(): + """异步列出语音""" + voices = await edge_tts.list_voices() + print("中文语音:") + for v in voices: + if v["Locale"].startswith("zh-"): + print(f" {v['ShortName']:30s} - {v['FriendlyName']}") + print(f"\n共 {len([v for v in voices if v['Locale'].startswith('zh-')])} 个中文语音") + + +def main(): + parser = argparse.ArgumentParser( + description="TTS Engine · Edge-TTS 配音引擎", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python tts-engine.py --text "你好世界" --output output.mp3 + python tts-engine.py --text "未来的天下第一宗!" --character "苏白" --output su-bai.mp3 + python tts-engine.py --text "Hello" --voice "en-US-JennyNeural" --output hello.mp3 + python tts-engine.py --srt input.srt --output-dir ./audio/ --character "苏白" + python tts-engine.py --list-voices +""" + ) + parser.add_argument("--text", help="要合成的文本") + parser.add_argument("--output", help="输出音频文件路径") + parser.add_argument("--voice", default="zh-CN-XiaoxiaoNeural", help="Edge-TTS 语音名") + parser.add_argument("--character", help="角色名(从配置文件读取音色)") + parser.add_argument("--rate", default="+0%", help="语速(如 +20%%、-10%%)") + parser.add_argument("--pitch", default="+0Hz", help="音调(如 +5Hz、-10Hz)") + parser.add_argument("--volume", default="+0%", help="音量(如 +10%%、-5%%)") + parser.add_argument("--srt", help="从SRT字幕文件批量生成") + parser.add_argument("--output-dir", help="批量生成时的输出目录") + parser.add_argument("--config", help="角色音色配置文件路径") + parser.add_argument("--list-voices", action="store_true", help="列出所有可用语音") + + args = parser.parse_args() + + if args.list_voices: + list_voices() + sys.exit(0) + + if args.srt: + # 批量模式 + if not args.output_dir: + print("[ERROR] 批量模式需要指定 --output-dir") + sys.exit(1) + config = load_voice_config(args.config) + ok = process_srt(args.srt, args.output_dir, args.character, config) + sys.exit(0 if ok else 1) + + if not args.text or not args.output: + print("[ERROR] 需要指定 --text 和 --output") + parser.print_help() + sys.exit(1) + + # 单文件模式 + if args.character: + config = load_voice_config(args.config) + ok = generate_by_character(args.text, args.output, args.character, config) + else: + ok = generate_speech( + args.text, args.output, + voice=args.voice, rate=args.rate, + pitch=args.pitch, volume=args.volume + ) + + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main()