Guanghu Domestic Migration a2fa7d57d8 chore: import sanitized domestic snapshot for REPO-005
Source snapshot: 23037fa3a2e9b0597162735755e92fc763bf4d94
2026-07-17 15:55:48 +08:00

2007 lines
88 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 · 蛋蛋短剧生产管线 · 命令行入口
# D181 · 2026-07-10 · v2.0
# Usage: python -m lib.cli <command> [args]
# 59 commands · 24 engines · 8 rounds of iteration
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from lib.models import get_db, init
def cmd_init():
init()
def cmd_project(action="list", project_id="", name=""):
"""项目管理: list / new / rm"""
db = get_db()
if action == "list":
rows = db.execute("SELECT id, name, created_at FROM projects ORDER BY created_at").fetchall()
if not rows:
print("还没有项目。用 project new <id> <name> 创建")
return
for r in rows:
eps = db.execute("SELECT COUNT(*) FROM episodes WHERE project_id = ?", (r["id"],)).fetchone()
print(f" {r['id']:20s} {r['name']:15s} {eps[0]}{r['created_at']}")
elif action == "new":
if not project_id or not name:
print("❌ 用法: project new <id> <名称>")
return
db.execute("INSERT OR IGNORE INTO projects (id, name) VALUES (?, ?)", (project_id, name))
db.commit()
print(f"✅ 项目 {project_id} ({name}) 已创建")
else:
print(f"❌ 未知操作: {action} (list/new)")
def cmd_episode(action="list", project_id="", episode_id="", number="1"):
"""剧集管理: list / new"""
db = get_db()
if action == "list":
rows = db.execute("""
SELECT e.id, e.project_id, e.number, e.status, p.name as project_name
FROM episodes e JOIN projects p ON e.project_id = p.id ORDER BY p.id, e.number
""").fetchall()
if not rows:
print("还没有剧集。用 episode new <project_id> <episode_id> [编号] 创建")
return
for r in rows:
shots = db.execute("SELECT COUNT(*) FROM shots WHERE episode_id = ?", (r["id"],)).fetchone()
print(f" {r['project_name']:12s} E{r['number']:02d} {r['id']:20s} {r['status']:8s} {shots[0]}")
elif action == "new":
if not project_id or not episode_id:
print("❌ 用法: episode new <project_id> <episode_id> [编号]")
return
# 验证项目存在
proj = db.execute("SELECT id FROM projects WHERE id = ?", (project_id,)).fetchone()
if not proj:
print(f"❌ 项目 {project_id} 不存在,先创建: project new {project_id} <名称>")
return
db.execute("INSERT OR IGNORE INTO episodes (id, project_id, number, status) VALUES (?, ?, ?, 'pending')",
(episode_id, project_id, int(number)))
db.commit()
print(f"✅ 剧集 {episode_id} (第{number}集) 已创建")
def cmd_asset(action="list", project_id="", asset_type="CHAR", name="", file_path=""):
"""资产管理: list / add / approve"""
db = get_db()
if action == "list":
pid = project_id or "deep-sea-voyage"
rows = db.execute("SELECT * FROM assets WHERE project_id = ? ORDER BY type, name", (pid,)).fetchall()
if not rows:
print(f"项目 {pid} 还没有资产")
return
for r in rows:
s = "" if r["status"] == "approved" else "🔴"
print(f" [{r['type']:6s}] {s} {r['name']:25s} {r['file_path']}")
elif action == "add":
if not project_id or not name or not file_path:
print("❌ 用法: asset add <project_id> CHAR|ENV|PROP <名称> <文件路径>")
return
aid = f"{project_id}-{asset_type}-{name}"
db.execute("INSERT OR REPLACE INTO assets (id, project_id, type, name, file_path, status) VALUES (?, ?, ?, ?, ?, 'pending')",
(aid, project_id, asset_type, name, file_path))
db.commit()
print(f"✅ 资产 {aid} 已添加")
elif action == "approve":
if not project_id or not name:
print("❌ 用法: asset approve <project_id> <资产名>")
return
db.execute("UPDATE assets SET status = 'approved', approved_at = datetime('now') WHERE project_id = ? AND name = ?",
(project_id, name))
db.commit()
print(f"✅ 资产 {name} 已批准")
def cmd_shot_list(episode="DSV-EP01"):
db = get_db()
rows = db.execute("""
SELECT shot_number, description, status, model, seedance_task_id, output_path, error_log
FROM shots WHERE episode_id = ? ORDER BY shot_number
""", (episode,)).fetchall()
if not rows:
print(f"No shots found for {episode}. Run: python -m lib.cli sync-shots")
return
print(f"\n{'#':4s} {'状态':10s} {'描述':40s} {'模型':25s} {'任务ID':30s}")
print("-" * 115)
for r in rows:
status_icon = {"pending":"🔴","generating":"🟡","done":"","failed":""}.get(r["status"], "")
desc = (r["description"] or "")[:38]
task = (r["seedance_task_id"] or "")[:28]
print(f"S{r['shot_number']:>2s} {status_icon} {r['status']:8s} {desc:40s} {r['model']:25s} {task:30s}")
def cmd_shot_status(shot_id):
db = get_db()
r = db.execute("""
SELECT * FROM shots WHERE id = ?
""", (shot_id,)).fetchone()
if not r:
print(f"Shot {shot_id} not found")
return
print(f"\nShot {r['shot_number']} · {r['status']}")
print(f" 描述: {r['description']}")
print(f" 剧本: {r['script_ref']}")
print(f" 镜头: {r['camera']} · {r['duration_sec']}s")
print(f" 模型: {r['model']} · {r['resolution']}")
print(f" 提示词: {r['prompt']}")
print(f" 任务ID: {r['seedance_task_id']}")
print(f" 输出: {r['output_path']}")
if r['error_log']:
print(f" ❌ 错误: {r['error_log']}")
# Show linked assets
assets = db.execute("""
SELECT a.type, a.name, sa.role FROM shot_assets sa
JOIN assets a ON sa.asset_id = a.id WHERE sa.shot_id = ?
""", (shot_id,)).fetchall()
if assets:
print(f" 关联资产:")
for a in assets:
print(f" [{a['role']}] {a['type']}: {a['name']}")
def cmd_asset_list(project="deep-sea-voyage"):
db = get_db()
rows = db.execute("""
SELECT id, type, name, status, approved_at FROM assets
WHERE project_id = ? ORDER BY type, name
""", (project,)).fetchall()
print(f"\n{'类型':6s} {'名称':25s} {'状态':10s} {'批准时间':20s} {'ID'}")
print("-" * 75)
for r in rows:
s = "" if r["status"] == "approved" else "🔴"
print(f"{r['type']:6s} {r['name']:25s} {s} {r['status']:8s} {(r['approved_at'] or ''):20s} {r['id']}")
def cmd_sync_shots():
"""Import shots from SHOT-LIST-EP01.hdlp into database"""
db = get_db()
shot_list_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"projects", "deep-sea-voyage", "shots", "SHOT-LIST-EP01.hdlp"
)
if not os.path.exists(shot_list_path):
print(f"Shot list not found: {shot_list_path}")
return
with open(shot_list_path, 'r', encoding='utf-8') as f:
content = f.read()
# Parse shots - simple regex approach
import re
shots = []
blocks = re.split(r'### Shot (\d+)', content)
for i in range(1, len(blocks), 2):
num = blocks[i]
text = blocks[i+1] if i+1 < len(blocks) else ""
shots.append((num, text.strip()))
for num, text in shots:
shot_id = f"DSV-EP01-S{num.zfill(2)}"
# Extract description
desc_match = re.search(r'\*\*剧本\*\*[:]\s*(.+?)(?:\n|$)', text)
desc = desc_match.group(1)[:80] if desc_match else f"Shot {num}"
# Extract duration
dur_match = re.search(r'(\d+)s', text.split('\n')[0] if '\n' in text else text)
dur = float(dur_match.group(1)) if dur_match else 6.0
# Determine status
status = "done" if num == "01" else "pending"
db.execute("""
INSERT OR REPLACE INTO shots
(id, episode_id, shot_number, description, script_ref, camera, duration_sec, status, output_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
shot_id, "DSV-EP01", f"S{num.zfill(2)}",
desc, f"EP01-SCRIPT-LOCK line {num}",
"9:16 vertical", dur, status,
f"outputs/videos/SHOT-{num.zfill(2)}.mp4" if status == "done" else None
))
db.commit()
print(f"Synced {len(shots)} shots from SHOT-LIST-EP01.hdlp")
def cmd_link_asset(shot_id, asset_id, role):
db = get_db()
db.execute("INSERT OR REPLACE INTO shot_assets (shot_id, asset_id, role) VALUES (?, ?, ?)",
(shot_id, asset_id, role))
db.commit()
print(f"Linked: {shot_id} ← [{role}] {asset_id}")
def cmd_task_log(shot_id=None):
db = get_db()
if shot_id:
rows = db.execute("""
SELECT * FROM generation_tasks WHERE shot_id = ? ORDER BY created_at DESC
""", (shot_id,)).fetchall()
else:
rows = db.execute("""
SELECT * FROM generation_tasks ORDER BY created_at DESC LIMIT 20
""").fetchall()
for r in rows:
icon = {"submitted":"📤","running":"🔄","succeeded":"","failed":""}.get(r["status"], "")
print(f"{icon} {r['id'][:20]:20s} {r['shot_id']:15s} {r['task_type']:12s} {r['status']}")
def cmd_exp_add(category, title, content):
db = get_db()
db.execute("INSERT INTO experience_log (category, title, content) VALUES (?, ?, ?)",
(category, title, content))
db.commit()
print(f"Experience logged: [{category}] {title}")
def cmd_exp_list(category=None):
db = get_db()
if category:
rows = db.execute("SELECT * FROM experience_log WHERE category = ? ORDER BY created_at DESC",
(category,)).fetchall()
else:
rows = db.execute("SELECT * FROM experience_log ORDER BY created_at DESC LIMIT 20").fetchall()
for r in rows:
print(f"[{r['category']}] {r['title']} ({r['created_at']})")
print(f" {r['content'][:120]}")
def cmd_chat(prompt):
from lib.doubao_chat import chat
r = chat(prompt)
if "content" in r:
print(r["content"])
else:
print(f"{r}")
def cmd_breakdown(file_path, episode_num=1):
from lib.doubao_chat import breakdown_script
with open(file_path, "r", encoding="utf-8") as f:
script = f.read()
r = breakdown_script(script, int(episode_num))
if "content" in r:
print(r["content"])
print(f"\n📊 {r['model']} · {r['tokens'].get('total_tokens',0)} tokens")
else:
print(f"{r}")
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} 完成")
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", max_parallel="3", retry="false", model=""):
"""批量提交生成任务: --max-parallel 3 --retry true --model kling|wan"""
from lib.seedance import batch_submit
mp = int(max_parallel)
rf = retry.lower() in ("true", "1", "yes")
m = (model or "").lower()
if m and m != "seedance":
# 非Seedance模型逐镜gen不走batch_submit
db = get_db()
where = "episode_id = ? AND status IN ('pending', 'failed')" if rf else "episode_id = ? AND status = 'pending'"
shots = db.execute(f"SELECT id FROM shots WHERE {where} ORDER BY shot_number", (episode_id,)).fetchall()
if not shots:
print("⚠️ 没有待生成的镜头")
return
print(f"🎬 批量 {m} 生成 {len(shots)} 镜...")
for s in shots:
cmd_gen(s["id"], m)
return
batch_submit(episode_id, max_parallel=mp, retry_failed=rf)
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("❌ 配音失败")
def cmd_image(mode="t2i", prompt="", ref="", output="", size="1440x2560"):
"""图像生成: t2i(文生图) / i2i(图生图) / multi(多图融合)"""
from lib.seedream import seedream_t2i, seedream_i2i, seedream_multi, seedream_download
if not prompt:
print("❌ 缺少提示词 prompt")
print("用法: python -m lib.cli image t2i \"提示词\" [ref路径] [输出路径] [尺寸]")
return
out = output or f"outputs/images/{mode}_{hash(prompt) & 0xFFFF:04x}.jpg"
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
print(f"🎨 {mode} 生成中...")
if mode == "t2i":
r = seedream_t2i(prompt, size)
elif mode == "i2i":
if not ref:
print("❌ i2i需要参考图路径: image i2i \"提示词\" ref.png")
return
r = seedream_i2i(prompt, ref, size)
elif mode == "multi":
refs = ref.split(",") if ref else []
if len(refs) < 2:
print("❌ multi需要至少2张参考图逗号分隔")
return
r = seedream_multi(prompt, refs, size)
else:
print(f"❌ 未知模式: {mode} (t2i/i2i/multi)")
return
if "error" in r:
print(f"{r['error']}")
return
p = seedream_download(r, out)
if p:
print(f"{p}")
else:
print(f"❌ 下载失败")
def cmd_qc(shot_id="", video_path="", character=""):
"""镜头质检: 竖屏/字幕/换脸/牌匾/遮挡/现代物品"""
import subprocess as _sp, json as _json
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not video_path and shot_id:
db = get_db()
shot = db.execute("SELECT output_path FROM shots WHERE id = ? AND status = 'done'", (shot_id,)).fetchone()
if shot and shot["output_path"]:
video_path = os.path.join(project_root, shot["output_path"])
if not video_path or not os.path.exists(video_path):
print(f"❌ 视频不存在: {video_path or shot_id}")
return
engine = os.path.join(project_root, "engines", "shot-qc-automation.py")
out = f"{video_path}.qc-report.json"
cmd = [sys.executable, engine, "--video", video_path, "--output", out]
if character: cmd += ["--character", character]
if not os.path.exists(engine):
print(f"⚠️ QC引擎未找到: {engine}")
return
print(f"🔍 质检 {os.path.basename(video_path)}...")
r = _sp.run(cmd, capture_output=True, text=True, timeout=120)
if r.returncode == 0 and os.path.exists(out):
report = _json.load(open(out, "r", encoding="utf-8"))
passed = report.get("passed", False)
score = report.get("score", 0)
print(f"{'✅ 通过' if passed else '❌ 未通过'} · 得分: {score:.1f}/10")
else:
print(f"⚠️ 质检完成无JSON报告: {r.stderr[:200] or r.stdout[:200]}")
def cmd_lipsync(shot_id="", video_path="", audio_path="", output=""):
"""口型同步: 视频+音频 → 口型匹配视频"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not video_path and shot_id:
db = get_db()
shot = db.execute("SELECT output_path FROM shots WHERE id = ? AND status = 'done'", (shot_id,)).fetchone()
if shot and shot["output_path"]:
video_path = os.path.join(project_root, shot["output_path"])
if not video_path or not audio_path:
print("❌ 用法: lipsync DSV-EP01-S01 配音.mp3")
return
engine = os.path.join(project_root, "engines", "lipsync-adapter.py")
if not os.path.exists(engine):
print(f"⚠️ 口型引擎未找到,跳过")
return
out = output or video_path.replace(".mp4", "-synced.mp4")
print(f"👄 口型同步...")
r = _sp.run([sys.executable, engine, "--video", video_path, "--audio", audio_path, "--output", out],
capture_output=True, text=True, timeout=300)
if r.returncode == 0: print(f"{out}")
else: print(f"⚠️ 口型同步失败: {r.stderr[:200]}")
def cmd_mix(video_path="", dialogue="", bgm="", output=""):
"""音频混音: 对白+BGM → 混音后合并视频"""
import subprocess as _sp, shutil
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not video_path or not dialogue:
print("❌ 用法: mix video.mp4 dialogue.mp3 [bgm.mp3]")
return
engine = os.path.join(project_root, "engines", "audio-mixer.py")
out = output or video_path.replace(".mp4", "-mixed.mp4")
mixed_audio = out.replace(".mp4", ".mp3")
if os.path.exists(engine):
cmd = [sys.executable, engine, "--dialogue", dialogue, "--output", mixed_audio]
if bgm and os.path.exists(bgm): cmd += ["--bgm", bgm]
print(f"🔊 混音...")
r = _sp.run(cmd, capture_output=True, text=True, timeout=60)
if r.returncode != 0:
print(f"⚠️ 引擎混音失败,用兜底方案: {r.stderr[:100]}")
from lib.tts import mix_audio_video
r2 = mix_audio_video(video_path, dialogue, out)
print(f"{'' if isinstance(r2, str) else ''} {r2}")
return
else:
from lib.tts import mix_audio_video
r2 = mix_audio_video(video_path, dialogue, out)
print(f"{'' if isinstance(r2, str) else ''} {r2}")
return
# 合并视频+混音
ffmpeg = shutil.which("ffmpeg") or os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe")
if os.path.exists(ffmpeg) and os.path.exists(mixed_audio):
r3 = _sp.run([ffmpeg, "-y", "-i", video_path, "-i", mixed_audio,
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest",
"-map", "0:v:0", "-map", "1:a:0", out],
capture_output=True, text=True, timeout=60)
print(f"{'' if r3.returncode == 0 else ''} {out}")
def cmd_emotion(text="", emotion="", output=""):
"""情感配音: 角色·情感·强度 → 带情感的TTS"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not text or not emotion:
print("❌ 用法: emotion \"台词\" \"苏白·大声·自信\" [output.mp3]")
print("情感格式: 角色名·情绪·强度 (如: 苏白·大声·自信)")
return
engine = os.path.join(project_root, "engines", "voice-emotion-compiler.py")
out = output or f"outputs/audio/emotion_{hash(text) & 0xFFFF:04x}.mp3"
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
if os.path.exists(engine):
print(f"🎭 情感配音: {emotion}")
r = _sp.run([sys.executable, engine, "--text", text, "--emotion", emotion, "--output", out],
capture_output=True, text=True, timeout=60)
if r.returncode == 0: print(f"{out}")
else: print(f"❌ 情感配音失败: {r.stderr[:200]}")
else:
# 兜底: 用普通TTS
from lib.tts import tts
r = tts(text, out)
print(f"⚠️ 情感引擎未找到用普通TTS兜底: {out}")
def cmd_subtitles(srt_path="", video_path="", output="", style="short-drama-bold"):
"""字幕渲染: SRT → 视频烧录5种样式"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not srt_path or not video_path:
print("❌ 用法: subtitles input.srt video.mp4 [output] [style]")
print("样式: reference-drama / short-drama-bold / clean-white / black-box / outlined-white")
return
engine = os.path.join(project_root, "engines", "subtitle-renderer.py")
out = output or video_path.replace(".mp4", "-subtitled.mp4")
if os.path.exists(engine):
print(f"📝 字幕渲染 ({style})...")
r = _sp.run([sys.executable, engine, "--srt", srt_path, "--video", video_path,
"--output", out, "--style", style],
capture_output=True, text=True, timeout=120)
if r.returncode == 0: print(f"{out}")
else: print(f"⚠️ 字幕引擎失败: {r.stderr[:200]}")
else:
# 兜底: FFmpeg subtitles filter
import shutil
ffmpeg = shutil.which("ffmpeg") or os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe")
if os.path.exists(ffmpeg):
print(f"📝 FFmpeg字幕烧录...")
r = _sp.run([ffmpeg, "-y", "-i", video_path,
"-vf", f"subtitles='{srt_path}':force_style='Fontsize=24,Alignment=2'",
"-c:a", "copy", out],
capture_output=True, text=True, timeout=60)
print(f"{'' if r.returncode == 0 else ''} {out}")
else:
print("❌ ffmpeg未找到")
def cmd_track(mode="track", video_path="", output=""):
"""平面追踪: track(特征追踪) / stabilize(稳定化) / pin(文字贴合) / motion(运动数据导出)"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not video_path:
print("❌ 用法: track track|stabilize|pin|motion video.mp4")
return
engine = os.path.join(project_root, "engines", "planar-tracker.py")
if not os.path.exists(engine):
print(f"⚠️ 追踪引擎未找到: {engine}")
return
out = output or f"{video_path}.track-{mode}.json"
print(f"🎯 平面追踪 ({mode})...")
r = _sp.run([sys.executable, engine, mode, video_path, "--output", out],
capture_output=True, text=True, timeout=120)
if r.returncode == 0:
print(f"{out}")
else:
print(f"{r.stderr[:200]}")
def cmd_director(script_path="", shots_dir="", output=""):
"""自动导演: 分镜→匹配镜头→追踪→分轨→时间线→成片"""
import subprocess as _sp, json as _json
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not script_path or not shots_dir:
print("❌ 用法: director 分镜.json 镜头目录/ [output.mp4]")
return
engine = os.path.join(project_root, "engines", "auto-cut-director.js")
out = output or f"outputs/episodes/director-{os.urandom(3).hex()}.mp4"
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
if not os.path.exists(engine):
print(f"⚠️ 导演引擎未找到,用 render 兜底")
# 兜底简单的concat
result = {"shots_dir": shots_dir, "script": script_path, "note": "director engine not found, use 'render' instead"}
print(f"💡 请改用: python -m lib.cli render")
return
# 构造 autoCut 配置
cfg = {
"script": script_path,
"shots": shots_dir,
"output": out,
"resolution": {"w": 1080, "h": 1920},
"fps": 24,
"stabilize": False,
"autoMix": True
}
cfg_json = out + ".director-cfg.json"
with open(cfg_json, "w", encoding="utf-8") as f:
_json.dump(cfg, f, ensure_ascii=False)
print(f"🎬 自动导演...")
r = _sp.run(["node", "-e",
f"const acd=require('{engine.replace(chr(92),'/')}');"
f"const cfg=require('{cfg_json.replace(chr(92),'/')}');"
f"acd.autoCut(cfg).then(r=>{{console.log(JSON.stringify(r));process.exit(0);}})"
f".catch(e=>{{console.error(e.message);process.exit(1);}})"],
capture_output=True, text=True, timeout=600)
if os.path.exists(cfg_json): os.unlink(cfg_json)
if r.returncode == 0:
try:
result = _json.loads(r.stdout)
print(f"{result.get('outputPath', out)}")
print(f" 时长: {result.get('duration', '?')}s")
except:
print(f"{out}")
else:
print(f"❌ 导演失败: {r.stderr[:300]}")
print(f"💡 请改用: python -m lib.cli render")
def cmd_pack(character="CHAR-003-SuBai", *extra_args):
"""角色资产生成: 多视角角色定妆照 → 自动批准入库
args: <角色ID> [--project <项目>] [--views front,side,hand] [--dry-run]
v2.0: 支持主视角→参考图链路 + seed锁定"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 检测引擎类型 (.js vs .py)
engine_js = os.path.join(project_root, "engines", "char-hero-design-packer", "char-hero-design-packer.js")
engine_py = os.path.join(project_root, "engines", "char-hero-design-packer", "char-hero-design-packer.py")
engine = None
runtime = None
if os.path.exists(engine_js):
engine = engine_js
runtime = "node"
elif os.path.exists(engine_py):
engine = engine_py
runtime = "python"
if not engine:
print(f"⚠️ 资产打包引擎未找到 (char-hero-design-packer.js/.py)")
print(f"💡 请用: python -m lib.cli image t2i \"角色定妆照描述\"")
return
# 解析参数
args = list(extra_args)
project = None
views = None
breakdown = None
dry_run = False
i = 0
while i < len(args):
if args[i] == "--project" and i+1 < len(args):
project = args[i+1]; i += 2
elif args[i] == "--views" and i+1 < len(args):
views = args[i+1]; i += 2
elif args[i] == "--breakdown" and i+1 < len(args):
breakdown = args[i+1]; i += 2
elif args[i] in ("--dry-run", "--dry_run", "true"):
dry_run = True; i += 1
else:
i += 1
if not project:
# 尝试从角色ID推断项目
import glob
proj_dirs = glob.glob(os.path.join(project_root, "projects", "*"))
for pd in proj_dirs:
breakdowns = glob.glob(os.path.join(pd, "*BREAKDOWN*V*.hdlp"))
for bd in breakdowns:
with open(bd, "r", encoding="utf-8") as f:
if character in f.read():
project = os.path.basename(pd)
print(f"🔍 自动检测项目: {project}")
break
if project: break
engine_args = ["--character", character, "--generate-all"]
engine_args.append("--project"); engine_args.append(project or "default")
if views: engine_args.append("--views"); engine_args.append(views)
if breakdown: engine_args.append("--breakdown"); engine_args.append(breakdown)
if dry_run: engine_args.append("--dry-run")
print(f"🎒 打包角色资产: {character}")
print(f" 项目: {project or '(未指定)'}")
print(f" 引擎: {os.path.basename(engine)} ({runtime})")
if views: print(f" 视角: {views}")
if dry_run: print(f" 🔍 干运行模式")
print()
if runtime == "node":
r = _sp.run(["C:/Users/rtyyr/.workbuddy/binaries/node/versions/22.22.2/node.exe", engine] + engine_args,
capture_output=True, text=True, timeout=600)
else:
r = _sp.run([sys.executable, engine] + engine_args,
capture_output=True, text=True, timeout=600)
if r.stderr:
for line in r.stderr.strip().split("\n"):
print(f" {line}")
if r.returncode == 0:
if not dry_run:
print(f"\n{character} 角色资产打包完成")
try:
db = get_db()
db.execute("UPDATE assets SET status = 'approved', approved_at = datetime('now') WHERE name LIKE ?",
(f"%{character}%",))
db.commit()
except: pass
else:
print(f"\n❌ 打包失败: {r.stderr[:300] or r.stdout[:300]}")
def cmd_prompt(scene_text="", output=""):
"""HLDP提示词: 场景描述 → Seedance提示词HLDP引擎/AI兜底"""
import subprocess as _sp, json as _json
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not scene_text:
print("❌ 用法: prompt \"场景描述\"")
print("示例: prompt \"苏白站在天道宗牌匾下,自信地喊话招生\"")
return
engine = os.path.join(project_root, "engines", "hldp-prompt.js")
if os.path.exists(engine):
print(f"🎯 HLDP提示词引擎...")
r = _sp.run(["node", "-e",
f"const hp=require('{engine.replace(chr(92),'/')}');"
f"const r=hp.understandScene({{description:'{scene_text}'}});"
f"console.log(JSON.stringify(r,null,2));"],
capture_output=True, text=True, timeout=30)
if r.returncode == 0 and r.stdout.strip():
try:
data = _json.loads(r.stdout)
for k, v in data.items() if isinstance(data, dict) else []:
if isinstance(v, str) and len(v) < 200:
print(f" {k}: {v}")
if not isinstance(data, dict):
print(r.stdout[:500])
except:
print(r.stdout[:500])
return
# 兜底: 豆包AI
from lib.doubao_chat import generate_keyframes
print(f"🤖 AI兜底...")
r = generate_keyframes(scene_text, [], [])
if "content" in r: print(r["content"])
else: print(f"{r}")
def cmd_produce(shot_id="", dry_run="false"):
"""单镜一站式生产: 资产→底片→配音→口型→字幕→混音→质检→报告"""
import subprocess as _sp
if not shot_id:
print("❌ 用法: produce <shot_id> [--dry-run]")
print("示例: produce E1-SHOT03")
return
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
engine = os.path.join(project_root, "engines", "ep01_shot03_production.py")
if not os.path.exists(engine):
print(f"⚠️ 生产编排引擎未找到")
print(f"💡 手工分步: image → gen → dub → lipsync → subtitles → mix → qc → render")
return
is_dry = dry_run.lower() in ("true", "1", "yes", "--dry-run")
flag = "--dry-run" if is_dry else "--run"
print(f"🎬 单镜生产: {shot_id}")
print(f" 步骤: 准备资产 → 生成底片 → 合成特效 → 配音 → 口型 → 字幕 → 混音 → 质检 → 报告")
if is_dry: print(f" 🔍 DRY RUN模式不实际执行")
r = _sp.run([sys.executable, engine, flag], capture_output=True, text=True, timeout=900)
print(r.stdout[-2000:] if len(r.stdout) > 2000 else r.stdout)
if r.returncode != 0:
print(f"❌ 生产失败: {r.stderr[:500]}")
def cmd_srt_timing(script_path="", audio_dir="", output=""):
"""剧本→SRT时间轴: 从剧本JSON+配音文件自动生成带时间戳的字幕"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not script_path:
print("❌ 用法: srt-timing 剧本.json 配音目录/ [输出.srt]")
print("剧本格式: JSON(含台词) / MD(含对话) / TXT(纯文本)")
return
engine = os.path.join(project_root, "engines", "subtitle-pipeline", "srt-tools", "script-to-srt-with-timing.py")
out = output or script_path.replace(".json", ".srt").replace(".md", ".srt").replace(".txt", ".srt")
if os.path.exists(engine):
print(f"⏱ 生成字幕时间轴...")
cmd = [sys.executable, engine, "--script", script_path, "--output", out]
if audio_dir: cmd += ["--audio-dir", audio_dir]
r = _sp.run(cmd, capture_output=True, text=True, timeout=60)
if r.returncode == 0: print(f"{out}")
else: print(f"{r.stderr[:300]}")
else:
# 兜底: 用 lib/tts.py 从文本生成简单SRT
print(f"⚠️ 字幕引擎未找到,用简单兜底方案")
with open(script_path, "r", encoding="utf-8") as f:
text = f.read()
lines = [l.strip() for l in text.split("\n") if l.strip() and not l.startswith("#")][:50]
from lib.tts import words_to_srt
# 每行假设3秒
words = [{"text": line, "start": i*3, "end": (i+1)*3} for i, line in enumerate(lines)]
words_to_srt(words, out)
print(f"{out} (简单时间轴每句3秒)")
def cmd_font(action="check"):
"""字幕字体管理: check(检测) / list(列表) / install(安装)"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
engine = os.path.join(project_root, "engines", "subtitle-pipeline", "font-manager", "subtitle-font-manager.py")
if not os.path.exists(engine):
print("⚠️ 字体管理器未找到")
print("💡 推荐中文字体: PingFang SC / Noto Sans SC / Source Han Sans")
return
if action in ("check", "list"):
print(f"🔤 检查字体...")
r = _sp.run([sys.executable, engine, "--check"], capture_output=True, text=True, timeout=30)
print(r.stdout[:1000] if r.stdout else r.stderr[:300])
elif action == "install":
print(f"📥 安装中文字体...")
r = _sp.run([sys.executable, engine, "--install"], capture_output=True, text=True, timeout=60)
print(r.stdout[:500] or "安装完成" if r.returncode == 0 else f"{r.stderr[:200]}")
def cmd_split_audio(video_path="", output_dir=""):
"""音频分轨: 视频 → 分离对白/BGM/音效三轨"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not video_path:
print("❌ 用法: split-audio video.mp4 [输出目录]")
return
engine = os.path.join(project_root, "engines", "audio-stem-splitter.js")
out_dir = output_dir or f"{video_path}.stems"
if os.path.exists(engine):
print(f"🎵 音频分轨...")
r = _sp.run(["node", "-e",
f"const sas=require('{engine.replace(chr(92),'/')}');"
f"sas.splitAudioStems('{video_path.replace(chr(92),'/')}',"
f"{{outputDir:'{out_dir.replace(chr(92),'/')}'}})"
f".then(r=>{{console.log(JSON.stringify(r));process.exit(0);}})"
f".catch(e=>{{console.error(e.message);process.exit(1);}})"],
capture_output=True, text=True, timeout=120)
if r.returncode == 0:
stems = ["voice.wav", "bgm.wav", "sfx.wav"]
found = [s for s in stems if os.path.exists(os.path.join(out_dir, s))]
print(f"✅ 分轨完成: {', '.join(found) if found else out_dir}")
else:
print(f"{r.stderr[:300]}")
else:
print(f"⚠️ 音频分轨引擎未找到")
print(f"💡 用FFmpeg手动分轨: ffmpeg -i video.mp4 -vn voice.wav")
def cmd_char_qc(image_path="", character="", video_path=""):
"""角色质检: 角色存在感评分(0-10) / 跨镜一致性对比"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
engine = os.path.join(project_root, "engines", "character-distinctiveness-qc", "character-distinctiveness-qc.py")
if not os.path.exists(engine):
print(f"⚠️ 角色QC引擎未找到")
return
if image_path and character:
print(f"🔍 角色质检: {character}")
r = _sp.run([sys.executable, engine, "--image", image_path, "--character", character],
capture_output=True, text=True, timeout=60)
print(r.stdout[:500] if r.stdout else f"{r.stderr[:200]}")
elif video_path and character:
print(f"🔍 角色跨镜一致性: {character}")
r = _sp.run([sys.executable, engine, "--batch", video_path, "--character", character],
capture_output=True, text=True, timeout=120)
print(r.stdout[:500] if r.stdout else f"{r.stderr[:200]}")
else:
print("❌ 用法: char-qc --image 角色图.png --character CHAR-003-SuBai")
print(" char-qc --video 镜头目录/ --character CHAR-003-SuBai")
def cmd_eye(video_path="", director_encoding=""):
"""铸渊之眼: 视频逐帧分析 → 与导演编码对比 → 差异报告"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not video_path:
print("❌ 用法: eye video.mp4 [导演编码.json]")
return
engine = os.path.join(project_root, "engines", "zhuyuan-eye.js")
cmd = ["node", engine, video_path]
if director_encoding: cmd.append(director_encoding)
if os.path.exists(engine):
print(f"👁 铸渊之眼分析...")
r = _sp.run(cmd, capture_output=True, text=True, timeout=120)
print(r.stdout[:1000] if r.stdout else f"{r.stderr[:300]}")
else:
print(f"⚠️ 铸渊之眼未找到")
def cmd_multi_ref(prompt="", references="", output="", action="check"):
"""多参考视频适配: check(API探测) / gen(多参考生成)"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
engine = os.path.join(project_root, "engines", "multi-reference-video-adapter.py")
if not os.path.exists(engine):
print(f"⚠️ 多参考适配器未找到")
return
if action == "check":
print(f"🔍 探测API能力...")
r = _sp.run([sys.executable, engine, "--check-api"], capture_output=True, text=True, timeout=30)
print(r.stdout[:1000] if r.stdout else r.stderr[:300])
elif action == "gen":
if not prompt or not references:
print("❌ 用法: multi-ref gen \"提示词\" \"ref1.png,ref2.png\" [output.mp4]")
return
refs = references.split(",")
out = output or f"outputs/videos/multi-ref-{hash(prompt) & 0xFFFF:04x}.mp4"
cmd = [sys.executable, engine, "--prompt", prompt, "--references"] + refs + ["--output", out]
print(f"🎬 多参考生成 ({len(refs)}张参考图)...")
r = _sp.run(cmd, capture_output=True, text=True, timeout=120)
if r.returncode == 0: print(f"{out}")
else: print(f"{r.stderr[:300]}")
else:
print("❌ 用法: multi-ref check|gen")
def cmd_parse(script_path="", output=""):
"""脚本解析: 原始剧本→结构化JSON (确定性解析不调AI)"""
import subprocess as _sp, json as _json
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not script_path:
print("❌ 用法: parse 剧本.txt [输出.json]")
print("支持格式: 腾讯文档(第N集/人物/△描述/名字(情绪):台词)")
return
engine = os.path.join(project_root, "engines", "script-parser.js")
out = output or script_path.replace(".txt", ".json").replace(".md", ".json")
if os.path.exists(engine) and os.path.exists(script_path):
with open(script_path, "r", encoding="utf-8") as f:
script_text = f.read()[:50000]
print(f"📖 解析剧本...")
r = _sp.run(["node", "-e",
f"const sp=require('{engine.replace(chr(92),'/')}');"
f"const r=sp.parseScript(`{script_text.replace('`','\\`').replace('$','\\$')}`);"
f"console.log(JSON.stringify(r,null,2));"],
capture_output=True, text=True, timeout=30)
if r.returncode == 0 and r.stdout.strip():
try:
data = _json.loads(r.stdout)
with open(out, "w", encoding="utf-8") as f:
_json.dump(data, f, ensure_ascii=False, indent=2)
stats = data.get("stats", {})
eps = data.get("episodes", [])
scenes = sum(len(ep.get("scenes", [])) for ep in eps)
chars = len(stats.get("characters", []))
print(f"{out}")
print(f" {len(eps)}{scenes}{chars}个角色")
except:
print(f"⚠️ 解析结果不是JSON直接输出:")
print(r.stdout[:500])
else:
# 兜底用AI
print(f"⚠️ 引擎解析失败用AI拆解...")
from lib.doubao_chat import breakdown_script
with open(script_path, "r", encoding="utf-8") as f:
text = f.read()
r = breakdown_script(text)
if "content" in r:
with open(out, "w", encoding="utf-8") as f:
f.write(r["content"])
print(f"{out} (AI拆解)")
else:
print(f"{r}")
else:
# 兜底AI
from lib.doubao_chat import breakdown_script
with open(script_path, "r", encoding="utf-8") as f:
text = f.read()
print(f"🤖 AI拆解...")
r = breakdown_script(text)
if "content" in r:
with open(out, "w", encoding="utf-8") as f:
f.write(r["content"])
print(f"{out}")
def cmd_adapt(director_json="", output=""):
"""导演编码适配: 导演JSON→video-editor.js编辑参数"""
import subprocess as _sp, json as _json
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not director_json:
print("❌ 用法: adapt 导演编码.json [输出编辑配置.json]")
return
engine = os.path.join(project_root, "engines", "hldp-director-adapter.js")
out = output or director_json.replace(".json", "-editor-cfg.json")
if os.path.exists(engine):
print(f"🎬 导演编码→编辑参数...")
r = _sp.run(["node", "-e",
f"const adapt=require('{engine.replace(chr(92),'/')}').adapt;"
f"const cfg=require('{director_json.replace(chr(92),'/')}');"
f"console.log(JSON.stringify(adapt(cfg),null,2));"],
capture_output=True, text=True, timeout=30)
if r.returncode == 0:
with open(out, "w", encoding="utf-8") as f:
f.write(r.stdout)
print(f"{out}")
else:
print(f"{r.stderr[:300]}")
else:
print(f"⚠️ 导演适配器未找到")
def cmd_batch_dub(episode_id="DSV-EP01", character=""):
"""批量配音: 为剧集所有镜头批量配音"""
from lib.tts import tts_for_character
db = get_db()
# 从剧本找台词简化版从shots表的script_ref和description拼接
shots = db.execute("""
SELECT id, shot_number, description, script_ref FROM shots
WHERE episode_id = ? ORDER BY shot_number
""", (episode_id,)).fetchall()
if not shots:
print(f"❌ 没有镜头,先 sync-shots")
return
print(f"🎭 批量配音 {len(shots)}")
if not character:
print("💡 未指定角色,将使用默认林昊音色")
character = "林昊"
for s in shots:
sid = s["id"]
# 从description提取可能的台词
desc = s["description"] or s["script_ref"] or f"镜头{s['shot_number']}"
text = desc.split("")[-1].split("")[-1].strip() if ("" in desc or "" in desc) else desc[:60]
audio_path = f"outputs/audio/{sid}-{character}.mp3"
srt_path = f"outputs/audio/{sid}-{character}.srt"
r = tts_for_character(text, character, audio_path)
if r and r.get("words"):
from lib.tts import words_to_srt
words_to_srt(r["words"], srt_path)
print(f"{sid}: {text[:30]}...")
else:
print(f"{sid}")
def cmd_batch_qc(episode_id="DSV-EP01"):
"""批量质检: 对剧集所有已完成镜头逐一QC"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
engine = os.path.join(project_root, "engines", "shot-qc-automation.py")
db = get_db()
shots = db.execute("""
SELECT id, shot_number, output_path FROM shots
WHERE episode_id = ? AND status = 'done' AND output_path IS NOT NULL
ORDER BY shot_number
""", (episode_id,)).fetchall()
if not shots:
print(f"❌ 没有已完成的镜头")
return
print(f"🔍 批量质检 {len(shots)}\n")
results = []
for s in shots:
video_path = os.path.join(project_root, s["output_path"])
if not os.path.exists(video_path):
print(f" ⚠️ {s['id']}: 视频文件不存在")
continue
out = f"{video_path}.qc-report.json"
r = _sp.run([sys.executable, engine, "--video", video_path, "--output", out],
capture_output=True, text=True, timeout=120)
if r.returncode == 0 and os.path.exists(out):
import json as _json
with open(out, "r", encoding="utf-8") as f:
report = _json.load(f)
passed = report.get("passed", False)
score = report.get("score", 0)
icon = "" if passed else ""
results.append({"shot": s["id"], "passed": passed, "score": score})
print(f" {icon} {s['id']}: {score:.1f}/10")
else:
print(f"{s['id']}: QC失败")
passed = sum(1 for r in results if r["passed"])
avg = sum(r["score"] for r in results) / len(results) if results else 0
print(f"\n📊 {passed}/{len(results)} 通过 · 均分: {avg:.1f}/10")
def cmd_env():
"""环境检测: 我在哪 / 有什么 / 能做什么"""
import shutil, socket
print(f"\n{'='*50}")
print(f"🖥 环境检测")
print(f"{'='*50}")
# 主机
hostname = socket.gethostname()
print(f"\n📍 主机: {hostname}")
print(f"🐍 Python: {sys.version.split()[0]}")
# 目录
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
print(f"📁 项目: {project_root}")
# FFmpeg
ffmpeg = shutil.which("ffmpeg") or os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe")
print(f"🎬 FFmpeg: {'' + ffmpeg if os.path.exists(ffmpeg) else '❌ 未找到'}")
# Node
node = shutil.which("node")
print(f"📦 Node: {'' + node if node else '❌ 未找到'}")
# 引擎数量
engines_dir = os.path.join(project_root, "engines")
if os.path.exists(engines_dir):
py = len([f for f in os.listdir(engines_dir) if f.endswith(".py")])
js = len([f for f in os.listdir(engines_dir) if f.endswith(".js")])
print(f"⚙ 引擎: {py}个Python + {js}个Node")
# API密钥
try:
from lib.secrets import get_ark_key
get_ark_key()
print(f"🔑 API密钥: ✅ 已配置")
except:
print(f"🔑 API密钥: ❌ 未设置 ARK_API_KEY")
# 数据库
db_path = os.path.join(os.path.dirname(__file__), "lib.db")
if os.path.exists(db_path):
db = get_db()
projs = db.execute("SELECT COUNT(*) FROM projects").fetchone()[0]
shots_total = db.execute("SELECT COUNT(*) FROM shots").fetchone()[0]
shots_done = db.execute("SELECT COUNT(*) FROM shots WHERE status = 'done'").fetchone()[0]
print(f"🗄 数据库: {projs}项目 {shots_total}镜({shots_done}完成)")
print(f"\n{'='*50}")
def cmd_config(section=""):
"""查看配置: agents(智能体) / voices(音色) / models(模型)"""
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not section or section == "agents":
agents_dir = os.path.join(project_root, "config", "agents")
if os.path.exists(agents_dir):
agents = sorted([f for f in os.listdir(agents_dir) if f.endswith(".hdlp")])
print(f"\n🤖 智能体 ({len(agents)}个):")
for a in agents:
print(f" {a}")
if not section or section == "voices":
try:
from lib.tts import VOICES
print(f"\n🎭 音色表 ({len(VOICES)}个):")
for name, voice in sorted(VOICES.items()):
print(f" {name:8s}{voice}")
except:
pass
if not section or section == "models":
from lib.doubao_chat import MODELS
print(f"\n🧠 模型:")
for k, v in MODELS.items():
print(f" {k:10s}{v}")
if section and section not in ("agents", "voices", "models"):
print(f"❌ 未知配置区: {section} (agents/voices/models)")
def cmd_backup():
"""数据库备份: 复制lib.db到带时间戳的备份文件"""
import shutil
from datetime import datetime
db_path = os.path.join(os.path.dirname(__file__), "lib.db")
if not os.path.exists(db_path):
print("❌ 数据库不存在,先 init")
return
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "outputs", "backups")
os.makedirs(backup_dir, exist_ok=True)
backup_path = os.path.join(backup_dir, f"lib-{ts}.db")
shutil.copy2(db_path, backup_path)
size = os.path.getsize(backup_path)
print(f"✅ 备份: {backup_path} ({size/1024:.0f}KB)")
# 清理旧备份保留最近10个
backups = sorted([f for f in os.listdir(backup_dir) if f.startswith("lib-")])
for old in backups[:-10]:
os.remove(os.path.join(backup_dir, old))
def cmd_workflow(name="", episode_id="DSV-EP01"):
"""工作流模板: make-ep(制作一集)"""
import subprocess as _sp
workflows = {"make-ep": "制作一集: parse-sync-batch-dub-qc-render"}
if not name:
print("📋 可用工作流:")
for k, v in workflows.items(): print(f" {k:12s} {v}")
return
if name == "make-ep":
steps = [("sync-shots", f"python -m lib.cli sync-shots"),
("batch", f"python -m lib.cli batch {episode_id}"),
("batch-dub", f"python -m lib.cli batch-dub {episode_id}"),
("batch-qc", f"python -m lib.cli batch-qc {episode_id}"),
("render", f"python -m lib.cli render {episode_id}")]
print(f"\n🎬 make-ep {episode_id}\n")
for i, (n, c) in enumerate(steps):
print(f"[{i+1}/{len(steps)}] {n}...")
_sp.run(c, shell=True, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
print(f"\n✅ make-ep 完成")
def cmd_info(video_path=""):
"""视频信息: 时长/分辨率/编码/大小"""
import subprocess as _sp, shutil
if not video_path:
print("❌ 用法: info video.mp4"); return
ffprobe = shutil.which("ffprobe") or shutil.which("ffmpeg")
if not ffprobe: print("❌ ffprobe/ffmpeg 未找到"); return
r = _sp.run([ffprobe, "-v", "quiet", "-show_entries", "format=duration,size", "-of", "csv=p=0", video_path],
capture_output=True, text=True, timeout=10)
parts = r.stdout.strip().split(",") if r.stdout.strip() else []
dur = float(parts[0]) if len(parts) > 0 and parts[0] else 0
sz = int(parts[1]) if len(parts) > 1 and parts[1] else 0
r2 = _sp.run([ffprobe, "-v", "quiet", "-select_streams", "v:0", "-show_entries", "stream=width,height,codec_name",
"-of", "csv=p=0", video_path], capture_output=True, text=True, timeout=10)
vp = r2.stdout.strip().split(",") if r2.stdout.strip() else []
print(f"\n📹 {os.path.basename(video_path)}")
print(f" 时长: {dur:.1f}s 大小: {sz/1048576:.1f}MB" if sz else f" 时长: {dur:.1f}s")
if len(vp) >= 2: print(f" 分辨率: {vp[0]}x{vp[1]} 编码: {vp[2] if len(vp)>2 else '?'}")
def cmd_characters(script_path=""):
"""角色提取: 从剧本解析角色列表"""
import subprocess as _sp, json as _json
if not script_path: print("❌ 用法: characters 剧本.txt"); return
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
engine = os.path.join(project_root, "engines", "script-parser.js")
if os.path.exists(engine) and os.path.exists(script_path):
with open(script_path, "r", encoding="utf-8") as f: script = f.read()[:50000]
r = _sp.run(["node", "-e", f"const sp=require('{engine.replace(chr(92),'/')}');const r=sp.extractAllCharacters(`{script.replace('`','\\`').replace('$','\\$')}`);console.log(JSON.stringify(r));"],
capture_output=True, text=True, timeout=30)
if r.returncode == 0 and r.stdout.strip():
try:
chars = _json.loads(r.stdout)
if isinstance(chars, list):
print(f"👥 {len(chars)}个角色:"); [print(f" · {c}") for c in chars]
else: print(r.stdout[:500])
except: print(r.stdout[:500])
else: print("⚠️ 无法解析,请用 parse 命令")
def cmd_sub_ab(video_path="", srt_path="", styles=""):
"""字幕A/B测试: 多种样式对比"""
import subprocess as _sp
if not video_path or not srt_path: print("❌ 用法: sub-ab video.mp4 subs.srt"); return
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
engine = os.path.join(project_root, "engines", "subtitle-pipeline", "ab-test-tools", "subtitle-style-ab-test.py")
out_dir = f"outputs/ab-test/{os.urandom(2).hex()}"
os.makedirs(out_dir, exist_ok=True)
if os.path.exists(engine):
cmd = [sys.executable, engine, "--video", video_path, "--srt", srt_path, "--output-dir", out_dir]
if styles:
for s in styles.split(","): cmd += ["--styles", s.strip()]
r = _sp.run(cmd, capture_output=True, text=True, timeout=120)
print(f"{'' if r.returncode==0 else ''} {out_dir}")
else: print("⚠️ A/B测试引擎未找到")
def cmd_ref_sub(image_path="", output=""):
"""参考字幕分析: 从截图提取字幕样式参数"""
import subprocess as _sp
if not image_path: print("❌ 用法: ref-sub 参考截图.png"); return
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
engine = os.path.join(project_root, "engines", "subtitle-pipeline", "reference-analysis", "reference-subtitle-analyzer.py")
out = output or f"{image_path}.sub-analysis.json"
if os.path.exists(engine):
r = _sp.run([sys.executable, engine, "--image", image_path, "--output", out], capture_output=True, text=True, timeout=30)
print(f"{'' if r.returncode==0 else ''} {out}")
else: print("⚠️ 参考分析引擎未找到")
def cmd_shot_set(shot_id="", status=""):
"""手动设置镜头状态: done / pending / failed"""
db = get_db()
if not shot_id or status not in ("done", "pending", "failed"):
print("❌ 用法: shot-set <shot_id> done|pending|failed")
return
db.execute("UPDATE shots SET status = ?, updated_at = datetime('now') WHERE id = ?", (status, shot_id))
db.commit()
print(f"{shot_id}{status}")
def cmd_clean(what="temp"):
"""清理: temp(临时文件) / outputs(输出目录) / db(重置数据库)"""
import shutil
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if what == "temp":
temps = ["outputs/temp_concat.txt"]
count = 0
for pattern in temps + ["*.director-cfg.json", "*.qc-report.json"]:
for f in os.listdir(os.path.join(project_root, "outputs")):
fp = os.path.join(project_root, "outputs", f)
if os.path.isfile(fp) and ("temp" in f or ".qc-report" in f or ".director-cfg" in f):
os.remove(fp); count += 1
db_path = os.path.join(os.path.dirname(__file__), "lib.db")
print(f"✅ 清理 {count} 个临时文件")
elif what == "outputs":
out_dir = os.path.join(project_root, "outputs")
if os.path.exists(out_dir):
size = sum(os.path.getsize(os.path.join(dp, f)) for dp, _, files in os.walk(out_dir) for f in files)
print(f"⚠️ 即将删除 outputs/ 目录 ({size/1048576:.1f}MB)")
print(" 确认? (此命令仅提示,不实际删除)")
print(" 手动执行: rm -rf outputs/")
elif what == "db":
db_path = os.path.join(os.path.dirname(__file__), "lib.db")
if os.path.exists(db_path):
print(f"⚠️ 即将重置数据库: {db_path}")
print(" 确认? (此命令仅提示,不实际删除)")
print(" 手动执行: rm lib.db && python -m lib.cli init")
else:
print(f"❌ 用法: clean temp|outputs|db")
"""工作流模板: make-ep(制作一集)"""
import subprocess as _sp
workflows = {
"make-ep": "制作一集: parse→sync→batch→batch-dub→batch-qc→render",
}
if not name:
print("📋 可用工作流:")
for k, v in workflows.items():
print(f" {k:12s} {v}")
print("\n用法: workflow make-ep [episode_id]")
return
if name == "make-ep":
steps = [
("sync-shots", f"python -m lib.cli sync-shots"),
("batch", f"python -m lib.cli batch {episode_id}"),
("batch-dub", f"python -m lib.cli batch-dub {episode_id}"),
("batch-qc", f"python -m lib.cli batch-qc {episode_id}"),
("render", f"python -m lib.cli render {episode_id}"),
]
print(f"\n🎬 工作流: make-ep {episode_id}")
print(f" 步骤: sync → batch → dub → qc → render\n")
for i, (name, cmd) in enumerate(steps):
print(f"[{i+1}/{len(steps)}] {name}...")
r = _sp.run(cmd, shell=True, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if r.returncode != 0 and name != "batch-qc":
print(f" ⚠️ {name} 返回非零: {r.returncode}")
print(f"\n✅ make-ep 完成")
def cmd_storyboard(script_path="", episode_num="1", output=""):
"""全剧分镜生成: 剧本→逐镜画面描述+景别+时长+角色"""
import subprocess as _sp, json as _json
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not script_path:
print("❌ 用法: storyboard 剧本.txt [集号] [输出.json]")
return
engine = os.path.join(project_root, "engines", "hldp-prompt.js")
out = output or script_path.replace(".txt", "-storyboard.md").replace(".md", "-storyboard.md")
if os.path.exists(engine) and os.path.exists(script_path):
with open(script_path, "r", encoding="utf-8") as f:
script = f.read()[:30000]
print(f"🎬 生成分镜脚本 (第{episode_num}集)...")
# Try episodeToStoryboard
scenes = [{"description": script[:2000]}] # simplified scene
r = _sp.run(["node", "-e",
f"const hp=require('{engine.replace(chr(92),'/')}');"
f"const r=hp.episodeToStoryboard({_json.dumps(scenes)},{episode_num});"
f"console.log(r||JSON.stringify(r));"],
capture_output=True, text=True, timeout=60)
if r.returncode == 0 and r.stdout.strip():
with open(out, "w", encoding="utf-8") as f:
f.write(r.stdout)
# Count shots roughly
shots_count = r.stdout.count("### Shot") or r.stdout.count("shot_number")
print(f"{out} (~{shots_count}镜)" if shots_count else f"{out}")
else:
# Fallback to AI
print(f"⚠️ HLDP引擎失败用AI兜底...")
from lib.doubao_chat import breakdown_script
r2 = breakdown_script(script, int(episode_num))
if "content" in r2:
with open(out, "w", encoding="utf-8") as f:
f.write(r2["content"])
print(f"{out} (AI)")
else:
print(f"{r2}")
else:
from lib.doubao_chat import breakdown_script
with open(script_path, "r", encoding="utf-8") as f:
script = f.read()
print(f"🤖 AI分镜...")
r = breakdown_script(script, int(episode_num))
if "content" in r:
with open(out, "w", encoding="utf-8") as f:
f.write(r["content"])
print(f"{out}")
def cmd_export(what="shots", episode_id="DSV-EP01", output=""):
"""数据导出: shots→JSON / assets→JSON / costs→CSV"""
import json as _json
db = get_db()
if what == "shots":
rows = db.execute("SELECT * FROM shots WHERE episode_id = ? ORDER BY shot_number", (episode_id,)).fetchall()
data = [dict(r) for r in rows]
out = output or f"exports/{episode_id}-shots.json"
elif what == "assets":
rows = db.execute("SELECT * FROM assets ORDER BY type, name").fetchall()
data = [dict(r) for r in rows]
out = output or "exports/assets.json"
elif what == "costs":
rows = db.execute("""
SELECT gt.*, s.shot_number FROM generation_tasks gt
JOIN shots s ON gt.shot_id = s.id ORDER BY gt.created_at
""").fetchall()
data = [dict(r) for r in rows]
out = output or "exports/costs.csv"
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
with open(out, "w", encoding="utf-8") as f:
f.write("shot,type,cost_estimated,cost_actual,status,created_at\n")
for r in data:
f.write(f"{r.get('shot_number','')},{r.get('task_type','')},{r.get('cost_estimated',0)},{r.get('cost_actual',0)},{r.get('status','')},{r.get('created_at','')}\n")
print(f"{out} ({len(data)}条)")
return
else:
print(f"❌ 未知导出类型: {what} (shots/assets/costs)")
return
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
with open(out, "w", encoding="utf-8") as f:
_json.dump(data, f, ensure_ascii=False, indent=2, default=str)
print(f"{out} ({len(data)}条)")
def cmd_safe_qc(video_path="", subtitle_ass="", output=""):
"""字幕安全区QC: 检测面部遮挡/贴边/对比度低"""
import subprocess as _sp
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not video_path:
print("❌ 用法: safe-qc video.mp4 [subtitles.ass]")
return
engine = os.path.join(project_root, "engines", "subtitle-pipeline", "qc-tools", "subtitle-safe-area-qc.py")
out = output or f"{video_path}.safe-qc.json"
if os.path.exists(engine):
cmd = [sys.executable, engine, "--video", video_path, "--output", out]
if subtitle_ass: cmd += ["--subtitle-ass", subtitle_ass]
print(f"🔍 字幕安全区QC...")
r = _sp.run(cmd, capture_output=True, text=True, timeout=60)
if r.returncode == 0:
import json as _json
if os.path.exists(out):
report = _json.load(open(out, "r", encoding="utf-8"))
issues = report.get("issues", []) if isinstance(report, dict) else []
print(f"{'✅ 安全' if not issues else '⚠️ ' + str(len(issues)) + '个问题'}")
else:
print(f"✅ QC完成")
else:
print(f"{r.stderr[:200]}")
else:
print(f"⚠️ 安全区QC引擎未找到")
def cmd_gen(shot_id, model=""):
"""提交视频生成任务: --model seedance|kling|wan, --dry-run, --seed <N>, --lock-seed"""
import subprocess as _sp
db = get_db()
shot = db.execute("SELECT * FROM shots WHERE id = ?", (shot_id,)).fetchone()
if not shot:
print(f"镜头 {shot_id} 不存在")
return
if shot["status"] == "done":
print(f"⚠️ {shot_id} 已完成。如需重跑请先 shot-set {shot_id} pending")
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()
ref_paths = [(r["role"], r["file_path"]) for r in refs]
prompt = shot["prompt"] or shot["description"]
if not prompt:
print(f"{shot_id} 没有提示词")
return
# --dry-run 模式
if model == "--dry-run" or model == "true":
print(f"\n🔍 DRY RUN: {shot_id}")
print(f" 提示词: {prompt[:100]}...")
print(f" 时长: {shot['duration_sec']}s")
print(f" 参考图: {len(ref_paths)}")
print(f" 锁定Seed: {shot['seed_value'] if shot['seed_value'] and shot['lock_seed'] else ''}")
print(f" 预估成本: ~¥{int(shot['duration_sec'] or 6) * 0.5:.1f}")
print(f"\n💡 确认后执行: python -m lib.cli gen {shot_id}")
return
# --lock-seed 模式
if model == "--lock-seed":
if shot["seed_value"]:
db.execute("UPDATE shots SET lock_seed = 1 WHERE id = ?", (shot_id,))
db.commit()
print(f"🔒 {shot_id} Seed={shot['seed_value']} 已锁定后续生成将复用此Seed保持角色一致")
else:
print(f"{shot_id} 还没有Seed值先生成一次")
return
# --seed 手动指定
seed_val = None
if model and model.startswith("--seed="):
seed_val = int(model.split("=")[1])
model = "seedance"
elif shot["lock_seed"] and shot["seed_value"]:
seed_val = shot["seed_value"]
print(f"🔒 使用锁定Seed={seed_val}")
m = (model or "seedance").lower()
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if m in ("kling", "wan"):
engine_name = f"{m}-official-adapter.js" if m == "kling" else f"{m}-api-adapter.js"
engine = os.path.join(project_root, "engines", engine_name)
if os.path.exists(engine):
print(f"🎬 {m.upper()}生成 {shot_id}...")
dur = int(shot["duration_sec"] or 6)
r = _sp.run(["node", "-e",
f"const gen=require('{engine.replace(chr(92),'/')}');"
f"gen.generateVideo({{prompt:`{prompt}`,duration:{dur},"
f"outputPath:'outputs/videos/{shot_id}.mp4'}})"
f".then(r=>{{console.log(JSON.stringify(r));process.exit(0);}})"
f".catch(e=>{{console.error(e.message);process.exit(1);}})"],
capture_output=True, text=True, timeout=120)
if r.returncode == 0:
db.execute("UPDATE shots SET status = 'generating', model = ? WHERE id = ?", (m, shot_id)); db.commit()
print(f"📤 {shot_id}{m.upper()}")
else: print(f"{r.stderr[:200]}")
else: print(f"⚠️ {m}引擎未找到回退Seedance"); m = "seedance"
if m == "seedance":
from lib.seedance import generate_shot_async
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} 已提交")
def cmd_flow(episode_id="DSV-EP01"):
"""管线可视化: 展示剧集生产流程当前进度"""
db = get_db()
shots = db.execute("""
SELECT shot_number, status, description, output_path, duration_sec
FROM shots WHERE episode_id = ? ORDER BY shot_number
""", (episode_id,)).fetchall()
if not shots:
print(f"{episode_id} 没有镜头")
return
icons = {"done": "", "generating": "🔄", "pending": "🔴", "failed": ""}
print(f"\n{'='*70}")
print(f"🔗 {episode_id} · 生产管线")
print(f"{'='*70}\n")
for i, s in enumerate(shots):
icon = icons.get(s["status"], "")
dur = f"{s['duration_sec']:.0f}s" if s["duration_sec"] else "?"
desc = (s["description"] or "")[:50]
# 绘制管线连接
if i == 0:
prefix = " "
else:
prev_status = shots[i-1]["status"]
connector = "" if prev_status == "done" else ""
prefix = f" {connector}"
print(f"{prefix}{icon} {s['shot_number']:6s} [{dur:4s}] {desc}")
# 统计
done = sum(1 for s in shots if s["status"] == "done")
total = len(shots)
total_dur = sum(s["duration_sec"] or 0 for s in shots if s["status"] == "done")
print(f"\n{''*70}")
print(f"📊 {done}/{total} 镜完成")
if done > 0:
print(f"⏱ 已完成: {total_dur:.0f}s")
print(f"🔜 下一步:")
if done == total:
print(f" python -m lib.cli render {episode_id}")
elif done > 0:
print(f" python -m lib.cli qc {shots[done-1]['shot_number']} # 质检最后一镜")
print(f" python -m lib.cli batch {episode_id} # 继续生成")
else:
print(f" python -m lib.cli batch {episode_id} # 开始批量生成")
else:
print(f"🔜 下一步: python -m lib.cli batch {episode_id}")
print(f"{'='*70}\n")
def cmd_shot_order(episode_id="DSV-EP01", shot_number="", new_number=""):
"""镜头重排: 调整镜头顺序"""
db = get_db()
if not shot_number or not new_number:
# 显示当前顺序
rows = db.execute("""
SELECT shot_number, description FROM shots
WHERE episode_id = ? ORDER BY shot_number
""", (episode_id,)).fetchall()
print(f"\n{episode_id} 镜头顺序:")
for r in rows:
print(f" {r['shot_number']:6s} {r['description'][:60] if r['description'] else ''}")
print("\n用法: shot-order DSV-EP01 S05 S03 (把S05移到S03位置)")
return
# 交换两个镜头的序号
db.execute("UPDATE shots SET shot_number = '__TEMP__' WHERE episode_id = ? AND shot_number = ?",
(episode_id, shot_number))
db.execute("UPDATE shots SET shot_number = ? WHERE episode_id = ? AND shot_number = ?",
(shot_number, episode_id, new_number))
db.execute("UPDATE shots SET shot_number = ? WHERE episode_id = ? AND shot_number = '__TEMP__'",
(new_number, episode_id))
db.commit()
print(f"{shot_number}{new_number}")
def cmd_batch_resume(episode_id="DSV-EP01"):
"""从中断恢复批量任务"""
from lib.seedance import batch_resume
batch_resume(episode_id)
def cmd_status(episode_id="DSV-EP01"):
"""查看剧集全局状态:镜头进度 + 资产批准率 + 预估时长"""
db = get_db()
# 镜头统计
shots = db.execute("""
SELECT status, COUNT(*) as count FROM shots
WHERE episode_id = ? GROUP BY status
""", (episode_id,)).fetchall()
# 资产统计
assets = db.execute("""
SELECT type, status, COUNT(*) as count FROM assets
GROUP BY type, status
""").fetchall()
# 成本统计
cost = db.execute("""
SELECT COALESCE(SUM(cost_estimated), 0) as estimated,
COALESCE(SUM(cost_actual), 0) as actual
FROM generation_tasks
""").fetchone()
print(f"\n{'='*50}")
print(f"📊 {episode_id} · 全局状态")
print(f"{'='*50}")
# 镜头
shot_map = {r["status"]: r["count"] for r in shots}
total_shots = sum(shot_map.values())
done = shot_map.get("done", 0)
generating = shot_map.get("generating", 0)
pending = shot_map.get("pending", 0)
failed_n = shot_map.get("failed", 0)
bar_len = 30
done_bar = int(done / max(total_shots, 1) * bar_len)
gen_bar = int(generating / max(total_shots, 1) * bar_len)
pend_bar = bar_len - done_bar - gen_bar
print(f"\n🎬 镜头: {total_shots}")
print(f" ✅ done: {done} 🔄 generating: {generating} 🔴 pending: {pending} ❌ failed: {failed_n}")
print(f" [{''*done_bar}{''*gen_bar}{'·'*pend_bar}] {done}/{total_shots}")
# 时长预估
if done > 0:
done_shots = db.execute("""
SELECT SUM(duration_sec) as total_dur FROM shots
WHERE episode_id = ? AND status = 'done'
""", (episode_id,)).fetchone()
print(f" ⏱ 已完成时长: {done_shots['total_dur']:.0f}s")
# 资产
print(f"\n📦 资产:")
asset_types = {}
for r in assets:
t = r["type"]
if t not in asset_types: asset_types[t] = {"approved": 0, "pending": 0}
asset_types[t][r["status"]] = r["count"]
for t, counts in asset_types.items():
total = counts["approved"] + counts["pending"]
print(f" {t}: {counts['approved']}/{total} 已批准")
# 成本
print(f"\n💰 成本:")
print(f" 预估: ¥{cost['estimated']:.2f} 实际(按token): {cost['actual']:.0f}")
print(f"\n{'='*50}")
def cmd_note(shot_id="", text=""):
"""镜头备注: 添加/查看镜头备注"""
db = get_db()
if not shot_id:
rows = db.execute("SELECT id, prompt FROM shots WHERE prompt LIKE '%@note:%'").fetchall()
if rows:
print("📝 带备注的镜头:")
for r in rows:
n = r["prompt"].split("@note:")[-1].strip()[:60]
print(f" {r['id']:20s} {n}")
else: print("还没有备注。用法: note <shot_id> <内容>")
return
if not text:
shot = db.execute("SELECT prompt FROM shots WHERE id = ?", (shot_id,)).fetchone()
if shot and shot["prompt"] and "@note:" in (shot["prompt"] or ""):
print(f"📝 {shot_id}: {shot['prompt'].split('@note:')[-1].strip()}")
else: print(f"💡 {shot_id} 暂无备注"); return
return
shot = db.execute("SELECT prompt FROM shots WHERE id = ?", (shot_id,)).fetchone()
if not shot: print(f"{shot_id} 不存在"); return
base = (shot["prompt"] or "").split("@note:")[0].strip()
db.execute("UPDATE shots SET prompt = ? WHERE id = ?", (f"{base}\n@note: {text}", shot_id))
db.commit()
print(f"{shot_id} 备注已保存")
def cmd_deps():
"""依赖检测: 检查Python/Node包是否安装"""
import importlib, shutil
print(f"\n{'='*50}")
print(f"📦 依赖检测")
print(f"{'='*50}\n")
# Python
py_deps = {"edge_tts": "Edge-TTS配音", "PIL": "Pillow图像处理",
"cv2": "OpenCV视觉", "numpy": "NumPy数值计算"}
print("🐍 Python:")
for mod, desc in py_deps.items():
try:
importlib.import_module(mod)
print(f"{mod:12s} {desc}")
except:
print(f"{mod:12s} {desc} — pip install {mod.split('.')[0]}")
# Node
print("\n📦 Node:")
node = shutil.which("node")
if node:
import subprocess as _sp
for pkg, desc in [("edge-tts","(Python包)"), ("sharp","图像处理"), ("ffmpeg-static","FFmpeg静态")]:
r = _sp.run([node, "-e", f"try{{require('{pkg}');process.exit(0)}}catch(e){{process.exit(1)}}"],
capture_output=True)
print(f" {'' if r.returncode==0 else '⚠️'} {pkg:15s} {desc}")
# Tools
print("\n🔧 工具:")
for tool in ["ffmpeg", "ffprobe", "git", "curl"]:
p = shutil.which(tool)
print(f" {'' if p else ''} {tool:10s} {''+p if p else '未安装'}")
print(f"\n{'='*50}")
def cmd_history(limit="20"):
"""生成历史: 最近的任务记录+成本"""
db = get_db()
rows = db.execute("""
SELECT gt.*, s.shot_number, s.episode_id FROM generation_tasks gt
JOIN shots s ON gt.shot_id = s.id ORDER BY gt.created_at DESC LIMIT ?
""", (int(limit),)).fetchall()
if not rows: print("还没有生成历史"); return
icons = {"submitted":"📤","running":"🔄","succeeded":"","failed":""}
print(f"\n📜 最近 {len(rows)}\n")
for r in rows:
i = icons.get(r["status"], "")
c = f"¥{r['cost_estimated']:.1f}" if r["cost_estimated"] else "?"
print(f" {i} {r['shot_number']:8s} {r['task_type']:6s} {r['status']:10s} {c:8s} {r['created_at']}")
def cmd_report(episode_id="DSV-EP01", output=""):
"""剧集总结报告: HTML格式(镜头+成本+时间线)"""
db = get_db()
shots = db.execute("""
SELECT s.*, COALESCE(SUM(gt.cost_estimated),0) as total_cost
FROM shots s LEFT JOIN generation_tasks gt ON s.id = gt.shot_id
WHERE s.episode_id = ? GROUP BY s.id ORDER BY s.shot_number
""", (episode_id,)).fetchall()
if not shots: print(f"{episode_id} 没有镜头"); return
costs = db.execute("""
SELECT COALESCE(SUM(cost_estimated),0) as est FROM generation_tasks gt
JOIN shots s ON gt.shot_id = s.id WHERE s.episode_id = ?
""", (episode_id,)).fetchone()
done = sum(1 for s in shots if s["status"] == "done")
total = len(shots)
dur = sum(s["duration_sec"] or 0 for s in shots if s["status"] == "done")
ico = {"done":"","generating":"🔄","pending":"🔴","failed":""}
out = output or f"outputs/reports/{episode_id}-report.html"
os.makedirs(os.path.dirname(out), exist_ok=True)
rows = "".join(
f"<tr><td>{ico.get(s['status'],'?')}</td><td>{s['shot_number']}</td>"
f"<td>{(s['duration_sec'] or 0):.0f}s</td><td>{(s['description'] or '')[:50]}</td>"
f"<td>¥{s['total_cost']:.1f}</td></tr>"
for s in shots
)
pct = done/max(total,1)*100
html = f"""<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8">
<title>{episode_id} 报告</title>
<style>body{{font-family:system-ui,sans-serif;max-width:900px;margin:40px auto;padding:20px;color:#333}}
h1{{border-bottom:2px solid #4A90D9;padding-bottom:10px}}
.g{{display:flex;gap:40px;margin:20px 0;padding:15px;background:#f5f7fa;border-radius:8px}}
.g div{{text-align:center}} .v{{font-size:24px;font-weight:bold;color:#4A90D9}}
table{{width:100%;border-collapse:collapse}} th,td{{padding:8px 12px;text-align:left;border-bottom:1px solid #eee}}
th{{background:#4A90D9;color:white}} .f{{margin-top:30px;color:#999;font-size:12px}}
.bar{{height:8px;background:#e0e0e0;border-radius:4px}} .bar div{{height:100%;background:#4A90D9;border-radius:4px;width:{pct:.0f}%}}</style></head><body>
<h1>🎬 {episode_id} · 生产报告</h1>
<div class="g"><div>📊 进度<div class="v">{done}/{total}</div></div>
<div>⏱ 已完成<div class="v">{dur:.0f}s</div></div>
<div>💰 成本<div class="v"{costs['est']:.1f}</div></div></div>
<div class="bar"><div></div></div>
<table><tr><th>状态</th><th>镜头</th><th>时长</th><th>描述</th><th>成本</th></tr>{rows}</table>
<div class="f">蛋蛋短剧生产管线 · cang-ying · D181</div></body></html>"""
with open(out, "w", encoding="utf-8") as f: f.write(html)
print(f"{out} ({os.path.getsize(out)/1024:.0f}KB)")
def cmd_inherit(source_ep="", target_ep=""):
"""跨集资产继承: 从源剧集复制资产到目标剧集"""
db = get_db()
if not source_ep or not target_ep:
eps = db.execute("SELECT id, project_id, number FROM episodes ORDER BY project_id, number").fetchall()
print("📋 现有剧集:"); [print(f" {e['project_id']:15s} {e['id']:20s} E{e['number']:02d}") for e in eps]
print("\n用法: inherit <源剧集> <目标剧集>")
return
src = db.execute("SELECT * FROM episodes WHERE id = ?", (source_ep,)).fetchone()
tgt = db.execute("SELECT * FROM episodes WHERE id = ?", (target_ep,)).fetchone()
if not src or not tgt: print("❌ 剧集不存在"); return
assets = db.execute("SELECT * FROM assets WHERE project_id = ?", (src["project_id"],)).fetchall()
count = 0
for a in assets:
new_id = a["id"].replace(src["project_id"], tgt["project_id"])
db.execute("""INSERT OR IGNORE INTO assets (id, project_id, type, name, file_path, status, approved_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(new_id, tgt["project_id"], a["type"], a["name"], a["file_path"], a["status"], a["approved_at"]))
count += 1
db.commit()
print(f"{source_ep}{target_ep}: 继承 {count} 个资产")
seeds = db.execute("SELECT seed_value, shot_number FROM shots WHERE episode_id = ? AND seed_value > 0 AND lock_seed = 1",
(source_ep,)).fetchall()
if seeds:
print(f"🔒 {len(seeds)} 个锁定Seed可复用:")
for s in seeds: print(f" {s['shot_number']}: --seed={s['seed_value']}")
def cmd_compare(shot_id="", models="seedance,kling,wan"):
"""A/B对比: 同镜头多模型并行生成"""
db = get_db()
if not shot_id: print("❌ 用法: compare <shot_id> [模型列表]"); return
ml = [m.strip() for m in models.split(",")]
cost = int(db.execute("SELECT duration_sec FROM shots WHERE id = ?", (shot_id,)).fetchone()["duration_sec"] or 6) * 0.5 * len(ml)
print(f"\n🔬 {shot_id} × {len(ml)}模型: {', '.join(m.upper() for m in ml)}")
print(f" 预估总成本: ~¥{cost:.1f}\n")
for m in ml:
if m in ("seedance", "kling", "wan"):
print(f" 📤 {m.upper()}..."); cmd_gen(shot_id, m)
print(f"\n💡 查看结果: python -m lib.cli status")
COMMANDS = {
"init": cmd_init, "status": cmd_status,
"project": cmd_project, "episode": cmd_episode,
"shots": cmd_shot_list, "shot": cmd_shot_status,
"assets": cmd_asset_list, "asset": cmd_asset, "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_gen, "collect": cmd_collect, "batch": cmd_batch,
"batch-resume": cmd_batch_resume,
"image": cmd_image,
"qc": cmd_qc, "lipsync": cmd_lipsync, "mix": cmd_mix,
"emotion": cmd_emotion, "subtitles": cmd_subtitles,
"srt-timing": cmd_srt_timing, "font": cmd_font, "split-audio": cmd_split_audio,
"char-qc": cmd_char_qc, "eye": cmd_eye, "multi-ref": cmd_multi_ref,
"track": cmd_track, "director": cmd_director,
"pack": cmd_pack, "prompt": cmd_prompt,
"parse": cmd_parse, "adapt": cmd_adapt,
"produce": cmd_produce,
"batch-dub": cmd_batch_dub, "batch-qc": cmd_batch_qc,
"env": cmd_env, "config": cmd_config, "backup": cmd_backup,
"workflow": cmd_workflow,
"storyboard": cmd_storyboard, "export": cmd_export, "safe-qc": cmd_safe_qc,
"shot-order": cmd_shot_order, "shot-set": cmd_shot_set,
"info": cmd_info, "characters": cmd_characters,
"sub-ab": cmd_sub_ab, "ref-sub": cmd_ref_sub,
"clean": cmd_clean,
"flow": cmd_flow, "history": cmd_history,
"report": cmd_report,
"note": cmd_note,
"deps": cmd_deps,
"inherit": cmd_inherit, "compare": cmd_compare,
"render": cmd_render, "compose": cmd_compose,
"dub": cmd_dub,
}
# ====== 命令帮助文本 ======
HELP_TEXT = {
"init": "init — 初始化数据库,创建默认项目",
"status": "status [episode_id] — 全局状态: 镜头进度+资产+成本",
"project": "project list|new <id> <name> — 项目管理",
"episode": "episode list|new <project_id> <episode_id> [编号] — 剧集管理",
"asset": "asset list|add|approve <project_id> <类型> <名> [路径] — 资产管理",
"shots": "shots [episode_id] — 镜头列表(状态图标)",
"shot": "shot <shot_id> — 镜头详情(含关联资产)",
"assets": "assets [project_id] — 资产列表",
"sync-shots":"sync-shots — 从分镜文件导入镜头到数据库",
"link": "link <shot_id> <asset_id> <角色> — 关联资产到镜头",
"tasks": "tasks [shot_id] — 生成任务日志",
"exp-add": "exp-add <分类> <标题> <内容> — 添加经验",
"exp-list": "exp-list [分类] — 经验列表",
"chat": "chat <提示词> — 豆包对话",
"breakdown": "breakdown <剧本文件> [集号] — AI剧本拆解",
"gen": "gen <shot_id> [--model seedance|kling|wan] [--dry-run] — 提交生成\n"
" --dry-run: 预览提示词和预估成本,不实际提交",
"collect": "collect <shot_id> — 收集生成结果",
"batch": "batch [episode_id] [max_parallel] [retry] — 批量并行生成",
"batch-resume":"batch-resume [episode_id] — 中断恢复",
"image": "image t2i|i2i|multi <提示词> [ref] [输出] [尺寸] — Seedream图像生成",
"qc": "qc <shot_id> — 镜头质检(竖屏/字幕/换脸/牌匾/遮挡/现代物品)",
"lipsync": "lipsync <shot_id> <配音.mp3> — 口型同步",
"mix": "mix <视频> <配音> [bgm] — 音频混音",
"emotion": "emotion <台词> <角色·情绪·强度> [输出] — 情感配音",
"subtitles": "subtitles <srt> <视频> [输出] [样式] — 字幕烧录(5种样式)",
"srt-timing":"srt-timing <剧本> [配音目录] [输出] — 剧本→自动时间轴SRT",
"font": "font check|list|install — 字幕字体管理",
"split-audio":"split-audio <视频> [输出目录] — 音频分轨(对白/BGM/音效)",
"char-qc": "char-qc --image <图> --character <角色ID> — 角色辨识度QC",
"eye": "eye <视频> [导演编码.json] — 铸渊之眼逐帧分析",
"multi-ref": "multi-ref check|gen <提示词> <ref1,ref2> — 多参考适配",
"track": "track track|stabilize|pin|motion <视频> — 平面追踪",
"director": "director <分镜.json> <镜头目录> — 自动导演编排",
"pack": "pack <角色ID> [--project <项目>] [--views front,side,hand] [--dry-run] — 角色资产打包(主视角→参考+seed锁定)",
"prompt": "prompt <场景描述> — HLDP提示词引擎",
"parse": "parse <剧本> [输出] — 确定性剧本解析+AI兜底",
"adapt": "adapt <导演编码.json> — 导演编码→编辑参数",
"produce": "produce <shot_id> [--dry-run] — 单镜9步一站式生产",
"batch-dub": "batch-dub [episode_id] [角色名] — 批量配音",
"batch-qc": "batch-qc [episode_id] — 批量质检",
"env": "env — 环境检测(主机/Python/FFmpeg/Node/引擎/API密钥/DB)",
"config": "config [agents|voices|models] — 查看配置",
"backup": "backup — 数据库备份(保留最近10份)",
"workflow": "workflow [make-ep] [episode_id] — 工作流模板",
"storyboard":"storyboard <剧本> [集号] [输出] — 全剧分镜生成",
"export": "export shots|assets|costs [episode_id] — 数据导出",
"safe-qc": "safe-qc <视频> [字幕.ass] — 字幕安全区QC",
"shot-order":"shot-order [episode_id] [from] [to] — 镜头重排",
"shot-set": "shot-set <shot_id> done|pending|failed — 手动设置状态",
"info": "info <视频> — 视频元数据(时长/分辨率/编码/大小)",
"characters":"characters <剧本> — 提取角色列表",
"sub-ab": "sub-ab <视频> <srt> [样式列表] — 字幕A/B测试",
"ref-sub": "ref-sub <截图> [输出] — 参考字幕样式分析",
"clean": "clean temp|outputs|db — 清理(仅提示,不实际删除)",
"flow": "flow [episode_id] — 管线可视化,显示每个镜头的进度和下一步建议",
"history": "history [数量] — 生成历史+成本一览",
"report": "report [episode_id] — 生成HTML生产报告",
"note": "note [shot_id] [内容] — 镜头备注增/查",
"ref-sub": "ref-sub <截图> [输出] — 参考字幕样式分析",
"render": "render [episode_id] — 一键成片",
"compose": "compose [episode_id] — 多镜头拼接",
"dub": "dub <shot_id> <台词> [角色名] — 配音+自动SRT",
}
if __name__ == "__main__":
if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
print("LIB · 蛋蛋短剧生产管线 · v2.0 · D181")
print("cang-ying · 光湖语言世界 · 胖头鱼语言子系统")
print(f"Python {sys.version.split()[0]} · {len(COMMANDS)} commands")
sys.exit(0)
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
print("LIB · 蛋蛋短剧生产管线 · v2.0 · D181")
print(f"Usage: python -m lib.cli <command> [args]")
print(f"Commands: {', '.join(sorted(COMMANDS.keys()))}")
if len(sys.argv) > 1:
print(f"\n❌ 未知命令: {sys.argv[1]}")
# 模糊匹配建议
import difflib
suggestions = difflib.get_close_matches(sys.argv[1], COMMANDS.keys(), n=3)
if suggestions:
print(f"💡 你是想说: {', '.join(suggestions)}?")
print(f"\n💡 python -m lib.cli <cmd> --help 查看命令帮助")
sys.exit(1)
cmd_name = sys.argv[1]
args = sys.argv[2:]
# --help 支持
if "--help" in args or "-h" in args:
help_text = HELP_TEXT.get(cmd_name, f"{cmd_name} — 无详细帮助")
print(f"📖 {help_text}")
sys.exit(0)
# --dry-run 透传 (gen/produce 专用)
if "--dry-run" in args:
args = [a for a in args if a != "--dry-run"] + ["true"] if cmd_name == "produce" else args
cmd = COMMANDS[cmd_name]
try:
cmd(*args)
except TypeError as e:
print(f"❌ 参数错误")
help_text = HELP_TEXT.get(cmd_name, "")
if help_text:
print(f"📖 {help_text}")
sys.exit(1)
except Exception as e:
print(f"💥 {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
sys.exit(1)