From 2eb451a41a8246b79bea1033b0a39ee6d8f65624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=89=B4=E5=BD=B1=20ICE-GL-CA001?= <565183519@qq.com> Date: Fri, 10 Jul 2026 11:48:17 +0800 Subject: [PATCH] =?UTF-8?q?D180=20=C2=B7=20LIB=20v1.1=20=C2=B7=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=B9=B6=E8=A1=8C+=E8=87=AA=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E8=AF=95+FFmpeg=E5=90=88=E6=88=90+=E8=87=AA=E5=8A=A8SRT?= =?UTF-8?q?=E5=AD=97=E5=B9=95+Edge-TTS=E9=85=8D=E9=9F=B3+18=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-ai-system/lib/cli.py | 83 +++++++++-------- video-ai-system/lib/composer.py | 157 ++++++++++++++++++++++++++++++++ video-ai-system/lib/seedance.py | 66 ++++++++++++++ video-ai-system/lib/tts.py | 61 +++++++++++-- 4 files changed, 323 insertions(+), 44 deletions(-) create mode 100644 video-ai-system/lib/composer.py diff --git a/video-ai-system/lib/cli.py b/video-ai-system/lib/cli.py index fc9f081..294b462 100644 --- a/video-ai-system/lib/cli.py +++ b/video-ai-system/lib/cli.py @@ -172,22 +172,6 @@ def cmd_breakdown(file_path, episode_num=1): else: print(f"❌ {r}") -COMMANDS = { - "init": cmd_init, - "shots": cmd_shot_list, - "shot": cmd_shot_status, - "assets": cmd_asset_list, - "sync-shots": cmd_sync_shots, - "link": cmd_link_asset, - "tasks": cmd_task_log, - "exp-add": cmd_exp_add, - "exp-list": cmd_exp_list, - "chat": cmd_chat, - "breakdown": cmd_breakdown, - "gen": cmd_generate, - "collect": cmd_collect, -} - def cmd_generate(shot_id): """提交 Seedance 生成任务(异步)""" from lib.seedance import generate_shot_async @@ -200,38 +184,61 @@ def cmd_generate(shot_id): if shot["status"] == "done": print(f"⚠️ {shot_id} 已完成,需爸爸确认是否重跑") return - - # 获取关联资产 - refs = db.execute(""" - SELECT sa.role, a.file_path FROM shot_assets sa - JOIN assets a ON sa.asset_id = a.id WHERE sa.shot_id = ? - """, (shot_id,)).fetchall() + refs = db.execute("SELECT sa.role, a.file_path FROM shot_assets sa JOIN assets a ON sa.asset_id = a.id WHERE sa.shot_id = ?", (shot_id,)).fetchall() ref_paths = [(r["role"], r["file_path"]) for r in refs] - prompt = shot["prompt"] or shot["description"] if not prompt: - print(f"❌ {shot_id} 没有提示词,请先设置") + print(f"❌ {shot_id} 没有提示词") return - r = generate_shot_async(shot_id, prompt, ref_paths, int(shot["duration_sec"] or 6)) - if "error" in r: - print(f"❌ {r['error']}") - else: - print(f"📤 {shot_id} 已提交 · task_id: {r['task_id'][:20]}...") - print(f" 轮询: python -m lib.cli collect {shot_id}") + if "error" in r: print(f"❌ {r['error']}") + else: print(f"📤 {shot_id} 已提交") def cmd_collect(shot_id): - """收集中间结果(检查+下载)""" from lib.seedance import check_and_collect r = check_and_collect(shot_id) - if r.get("error"): - print(f"❌ {r['error']}") - elif r.get("status") == "done": - print(f"✅ {shot_id} 完成 → {r['output_path']}") - elif r.get("status") == "running": - print(f"🔄 {shot_id} 生成中(ID: {r['task_id'][:20]}...)") + if r.get("error"): print(f"❌ {r['error']}") + elif r.get("status") == "done": print(f"✅ {shot_id} 完成") + elif r.get("status") == "running": print(f"🔄 生成中") + else: print(f"❓ {r}") + +def cmd_render(episode_id="DSV-EP01"): + from lib.composer import render_episode + r = render_episode(episode_id) + if "error" in r: print(f"❌ {r['error']}") + else: print(f"✅ {r['shot_count']}镜 {r['total_duration']}s → {r['output_path']}") + +def cmd_compose(episode_id="DSV-EP01"): + from lib.composer import concat_shots + r = concat_shots(episode_id) + if "error" in r: print(f"❌ {r['error']}") + else: print(f"✅ {r['shot_count']}镜拼接 → {r['output_path']}") + +def cmd_batch(episode_id="DSV-EP01"): + from lib.seedance import batch_submit + batch_submit(episode_id) + +def cmd_dub(shot_id, text, character="林昊"): + """配音+自动SRT""" + from lib.tts import tts_for_character, words_to_srt + audio_path = f"projects/deep-sea-voyage/audio/{shot_id}-{character}.mp3" + srt_path = f"projects/deep-sea-voyage/audio/{shot_id}-{character}.srt" + r = tts_for_character(text, character, audio_path) + if r and r["words"]: + srt = words_to_srt(r["words"], srt_path) + print(f"✅ {len(r['words'])}句 → {srt}") else: - print(f"❓ {r}") + print("❌ 配音失败") + +COMMANDS = { + "init": cmd_init, "shots": cmd_shot_list, "shot": cmd_shot_status, + "assets": cmd_asset_list, "sync-shots": cmd_sync_shots, "link": cmd_link_asset, + "tasks": cmd_task_log, "exp-add": cmd_exp_add, "exp-list": cmd_exp_list, + "chat": cmd_chat, "breakdown": cmd_breakdown, + "gen": cmd_generate, "collect": cmd_collect, "batch": cmd_batch, + "render": cmd_render, "compose": cmd_compose, + "dub": cmd_dub, +} if __name__ == "__main__": if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: diff --git a/video-ai-system/lib/composer.py b/video-ai-system/lib/composer.py new file mode 100644 index 0000000..4489534 --- /dev/null +++ b/video-ai-system/lib/composer.py @@ -0,0 +1,157 @@ +# LIB · 成片合成模块 +# D180 · 2026-07-10 +"""FFmpeg: 多镜头拼接 + 音频合成 + 字幕叠加 → 导出成片""" +import os, subprocess, json +from lib.models import get_db + +FFMPEG = os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe") +PROJECT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +def concat_shots(episode_id, output_path=None, add_fade=True): + """ + 从数据库读取已完成镜头 → FFmpeg 拼接 → 导出成片 + + Args: + episode_id: 剧集ID + output_path: 输出路径(默认 outputs/episodes/EP01-final.mp4) + add_fade: 镜头间加淡入淡出 + """ + if not os.path.exists(FFMPEG): + return {"error": f"ffmpeg not found: {FFMPEG}"} + + db = get_db() + shots = db.execute(""" + SELECT id, shot_number, output_path, duration_sec FROM shots + WHERE episode_id = ? AND status = 'done' + ORDER BY shot_number + """, (episode_id,)).fetchall() + + if not shots: + return {"error": f"No completed shots for {episode_id}"} + + # 检查所有文件存在 + for s in shots: + if not s["output_path"] or not os.path.exists(os.path.join(PROJECT, s["output_path"])): + return {"error": f"Missing: {s['shot_number']} → {s['output_path']}"} + + if not output_path: + out_dir = os.path.join(PROJECT, "outputs", "episodes") + os.makedirs(out_dir, exist_ok=True) + output_path = os.path.join(out_dir, f"{episode_id}-final.mp4") + else: + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # 生成 ffmpeg concat 文件列表 + concat_list = os.path.join(PROJECT, "outputs", "temp_concat.txt") + with open(concat_list, "w", encoding="utf-8") as f: + for s in shots: + abs_path = os.path.join(PROJECT, s["output_path"]).replace("\\", "/") + f.write(f"file '{abs_path}'\n") + f.write(f"duration {s['duration_sec']}\n") + # 最后一个文件需要重复一次(ffmpeg concat 约定) + last = os.path.join(PROJECT, shots[-1]["output_path"]).replace("\\", "/") + f.write(f"file '{last}'\n") + + # FFmpeg concat + cmd = [ + FFMPEG, "-y", + "-f", "concat", + "-safe", "0", + "-i", concat_list, + "-c:v", "libx264", + "-preset", "fast", + "-crf", "23", + "-c:a", "aac", + "-pix_fmt", "yuv420p", + output_path + ] + + r = subprocess.run(cmd, capture_output=True, text=True) + os.unlink(concat_list) + + if r.returncode != 0: + return {"error": r.stderr[:500]} + + return { + "output_path": output_path, + "shot_count": len(shots), + "total_duration": sum(s["duration_sec"] for s in shots), + "shots": [s["shot_number"] for s in shots] + } + +def add_audio_and_subtitle(video_path, audio_paths, subtitle_texts, output_path): + """ + 视频 + 配音 + 字幕 → 合成 + + audio_paths: [{"path": "...", "start": 3.5}, ...] 音频文件和起始时间(秒) + subtitle_texts: [{"text": "...", "start": 3.5, "end": 8.5}, ...] + """ + if not os.path.exists(FFMPEG): + return {"error": f"ffmpeg not found: {FFMPEG}"} + + asrc = "" # no original audio + + # Build audio inputs + audio_inputs = [] + audio_filters = [] + for i, a in enumerate(audio_paths): + audio_inputs.extend(["-i", a["path"]]) + delay_ms = int(a["start"] * 1000) + audio_filters.append(f"[{i+1}:a]adelay={delay_ms}|{delay_ms}[a{i}]") + + # Build subtitle filter + sub_parts = [] + for s in subtitle_texts: + esc = s["text"].replace("'", "\\'").replace(":", "\\:") + sub_parts.append(f"drawtext=text='{esc}':fontsize=28:fontcolor=white:borderw=2:bordercolor=black:x=(w-text_w)/2:y=h-120:enable='between(t,{s['start']},{s['end']})'") + sub_filter = ",".join(sub_parts) if sub_parts else "null" + + # Audio mix + if audio_inputs: + n = len(audio_inputs) + amix = "".join(audio_filters) + f"amix=inputs={n}:duration=first[aout]" + else: + amix = "" + + filter_complex = f"[0:v]{sub_filter}[vout];{amix}" if audio_inputs else f"[0:v]{sub_filter}[vout]" + + cmd = [FFMPEG, "-y", "-i", video_path] + audio_inputs + [ + "-filter_complex", filter_complex, + "-map", "[vout]", + "-map", "[aout]" if audio_inputs else "-an", + "-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", + "-pix_fmt", "yuv420p", + output_path + ] + + r = subprocess.run(cmd, capture_output=True, text=True) + return output_path if r.returncode == 0 else {"error": r.stderr[:500]} + +def render_episode(episode_id): + """ + 一键成片:数据库 → 拼接所有已完成镜头 → 导出 + """ + print(f"🎬 合成 {episode_id}...") + + # Step 1: Concat all done shots + result = concat_shots(episode_id) + if "error" in result: + return result + + print(f"✅ 拼接完成: {result['shot_count']}镜 {result['total_duration']}s") + print(f"📁 {result['output_path']}") + return result + +if __name__ == "__main__": + import sys + if len(sys.argv) < 2: + print("Usage: python composer.py concat ") + print(" python composer.py render ") + sys.exit(1) + cmd = sys.argv[1] + ep = sys.argv[2] if len(sys.argv) > 2 else "DSV-EP01" + if cmd == "concat": + print(json.dumps(concat_shots(ep), indent=2, ensure_ascii=False)) + elif cmd == "render": + print(json.dumps(render_episode(ep), indent=2, ensure_ascii=False)) diff --git a/video-ai-system/lib/seedance.py b/video-ai-system/lib/seedance.py index 754b22b..e5b9775 100644 --- a/video-ai-system/lib/seedance.py +++ b/video-ai-system/lib/seedance.py @@ -147,3 +147,69 @@ def check_and_collect(shot_id): db.commit() return {"status": "done", "output_path": output_path, "seed": result.get("seed")} return {"error": "download failed"} + +# ====== 批量并行生成 + 自动重试 ====== + +def batch_submit(episode_id, max_parallel=3, retry_failed=False): + """批量提交:自动读库中所有 pending 镜头 → 并行提交 → 并行轮询""" + from lib.models import get_db + db = get_db() + + where = "episode_id = ? AND status IN ('pending', 'failed')" if retry_failed else "episode_id = ? AND status = 'pending'" + shots = db.execute(f"SELECT * FROM shots WHERE {where} ORDER BY shot_number", (episode_id,)).fetchall() + + if not shots: + print("⚠️ 没有待生成的镜头") + return + + print(f"🚀 批量提交 {len(shots)} 个镜头(最多并行 {max_parallel})\n") + + results = {} + queue = list(shots) + + while queue or any(r.get("status") == "running" for r in results.values()): + while len([r for r in results.values() if r.get("status") in ("submitted", "running")]) < max_parallel and queue: + shot = queue.pop(0) + shot_id = shot["id"] + print(f"📤 提交 {shot_id}...") + refs = db.execute("SELECT sa.role, a.file_path FROM shot_assets sa JOIN assets a ON sa.asset_id = a.id WHERE sa.shot_id = ?", (shot_id,)).fetchall() + ref_paths = [(r["role"], r["file_path"]) for r in refs] if refs else [] + prompt = shot["prompt"] or shot["description"] + r = generate_shot_async(shot_id, prompt, ref_paths, int(shot["duration_sec"] or 6)) + if "error" in r: + results[shot_id] = {"status": "failed", "error": r["error"], "retries": 0} + print(f" ❌ {r['error']}") + else: + results[shot_id] = {"status": "running", "task_id": r["task_id"], "retries": 0} + print(f" ✅ {r['task_id'][:20]}...") + + for shot_id, info in list(results.items()): + if info["status"] != "running": continue + result = check_and_collect(shot_id) + if result.get("status") == "done": + results[shot_id] = {"status": "done", "output_path": result["output_path"]} + print(f"✅ {shot_id} 完成") + elif result.get("error") and "timeout" not in str(result.get("error", "")): + info["retries"] += 1 + if info["retries"] <= 2: + print(f"🔄 {shot_id} 重试 ({info['retries']}/2)") + db.execute("UPDATE shots SET status = 'pending' WHERE id = ?", (shot_id,)); db.commit() + refs = db.execute("SELECT sa.role, a.file_path FROM shot_assets sa JOIN assets a ON sa.asset_id = a.id WHERE sa.shot_id = ?", (shot_id,)).fetchall() + ref_paths = [(r["role"], r["file_path"]) for r in refs] + shot = db.execute("SELECT * FROM shots WHERE id = ?", (shot_id,)).fetchone() + prompt = shot["prompt"] or shot["description"] + new_r = generate_shot_async(shot_id, prompt, ref_paths, int(shot["duration_sec"] or 6)) + if "error" not in new_r: + results[shot_id] = {"status": "running", "task_id": new_r["task_id"], "retries": info["retries"]} + else: + results[shot_id] = {"status": "failed", "error": new_r["error"], "retries": info["retries"]} + else: + results[shot_id] = {"status": "failed", "retries": info["retries"]} + db.execute("UPDATE shots SET status = 'failed', error_log = ? WHERE id = ?", (str(result.get("error", ""))[:200], shot_id)); db.commit() + print(f"❌ {shot_id} 重试{info['retries']}次后失败") + time.sleep(30) + + done = sum(1 for r in results.values() if r["status"] == "done") + failed = sum(1 for r in results.values() if r["status"] == "failed") + print(f"\n📊 批量完成: {done}✅ {failed}❌ (共{len(results)})") + return results diff --git a/video-ai-system/lib/tts.py b/video-ai-system/lib/tts.py index 637f878..c89a13e 100644 --- a/video-ai-system/lib/tts.py +++ b/video-ai-system/lib/tts.py @@ -1,7 +1,7 @@ -# LIB · TTS 配音模块 +# LIB · TTS 配音模块 + 自动字幕生成 # D180 · 2026-07-10 -"""Edge-TTS 配音引擎 · 多角色音色""" -import asyncio, edge_tts, os +"""Edge-TTS 配音引擎 · 多角色音色 · 自动SRT字幕""" +import asyncio, edge_tts, os, json # 角色音色表 VOICES = { @@ -14,18 +14,67 @@ VOICES = { FFMPEG = os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe") def tts(text, output_path, voice="zh-CN-YunxiNeural", rate="+0%"): - """生成语音 mp3""" + """生成语音 mp3(返回时间戳用于字幕)""" os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + words = [] async def _run(): - await edge_tts.Communicate(text, voice, rate=rate).save(output_path) + 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 output_path if os.path.exists(output_path) else None + 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):