D180 · LIB v1.1 · 批量并行+自动重试+FFmpeg合成+自动SRT字幕+Edge-TTS配音+18命令
This commit is contained in:
parent
2cd1e39304
commit
2eb451a41a
@ -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:
|
||||
|
||||
157
video-ai-system/lib/composer.py
Normal file
157
video-ai-system/lib/composer.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user