D180 · LIB v1.1 · 批量并行+自动重试+FFmpeg合成+自动SRT字幕+Edge-TTS配音+18命令

This commit is contained in:
鉴影 ICE-GL-CA001 2026-07-10 11:48:17 +08:00
parent 2cd1e39304
commit 2eb451a41a
4 changed files with 323 additions and 44 deletions

View File

@ -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:

View File

@ -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 <episode_id>")
print(" python composer.py render <episode_id>")
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))

View File

@ -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

View File

@ -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):