# LIB · 蛋蛋短剧生产管线 · 命令行入口 # D181 · 2026-07-10 · v2.0 # Usage: python -m lib.cli [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 创建") 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 <名称>") 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 [编号] 创建") 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 [编号]") 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 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 <资产名>") 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 [--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 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 , --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 <内容>") 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"{ico.get(s['status'],'?')}{s['shot_number']}" f"{(s['duration_sec'] or 0):.0f}s{(s['description'] or '')[:50]}" f"¥{s['total_cost']:.1f}" for s in shots ) pct = done/max(total,1)*100 html = f""" {episode_id} 报告

🎬 {episode_id} · 生产报告

📊 进度
{done}/{total}
⏱ 已完成
{dur:.0f}s
💰 成本
¥{costs['est']:.1f}
{rows}
状态镜头时长描述成本
蛋蛋短剧生产管线 · cang-ying · D181
""" 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 [模型列表]"); 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 — 项目管理", "episode": "episode list|new [编号] — 剧集管理", "asset": "asset list|add|approve <类型> <名> [路径] — 资产管理", "shots": "shots [episode_id] — 镜头列表(状态图标)", "shot": "shot — 镜头详情(含关联资产)", "assets": "assets [project_id] — 资产列表", "sync-shots":"sync-shots — 从分镜文件导入镜头到数据库", "link": "link <角色> — 关联资产到镜头", "tasks": "tasks [shot_id] — 生成任务日志", "exp-add": "exp-add <分类> <标题> <内容> — 添加经验", "exp-list": "exp-list [分类] — 经验列表", "chat": "chat <提示词> — 豆包对话", "breakdown": "breakdown <剧本文件> [集号] — AI剧本拆解", "gen": "gen [--model seedance|kling|wan] [--dry-run] — 提交生成\n" " --dry-run: 预览提示词和预估成本,不实际提交", "collect": "collect — 收集生成结果", "batch": "batch [episode_id] [max_parallel] [retry] — 批量并行生成", "batch-resume":"batch-resume [episode_id] — 中断恢复", "image": "image t2i|i2i|multi <提示词> [ref] [输出] [尺寸] — Seedream图像生成", "qc": "qc — 镜头质检(竖屏/字幕/换脸/牌匾/遮挡/现代物品)", "lipsync": "lipsync <配音.mp3> — 口型同步", "mix": "mix <视频> <配音> [bgm] — 音频混音", "emotion": "emotion <台词> <角色·情绪·强度> [输出] — 情感配音", "subtitles": "subtitles <视频> [输出] [样式] — 字幕烧录(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 <提示词> — 多参考适配", "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 [--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 done|pending|failed — 手动设置状态", "info": "info <视频> — 视频元数据(时长/分辨率/编码/大小)", "characters":"characters <剧本> — 提取角色列表", "sub-ab": "sub-ab <视频> [样式列表] — 字幕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 <台词> [角色名] — 配音+自动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 [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 --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)