EED-129 · LIB v2.0 · 管线全面升级

铁律合规:
- P0: 移除三处硬编码API密钥 → lib/secrets.py 三层查找
- P0: 统一LIB/engines调用关系(tts复用音色表,composer桥接editor)
- P0: batch支持参数+batch-resume断点续传
- P0: Seed一致性(seed_value/lock_seed字段+gen --lock-seed)
- P0: 跨集资产继承(inherit命令)+A/B对比(compare命令)
- P1: concat逻辑修复/滤镜崩溃修复/FFmpeg路径统一/成本追踪
- P2: 状态判断修复/CLI --help+--version+模糊匹配
- 新增: 65条命令(原17条)覆盖全链路
- 新增: deps/info/note/history/report/flow/director等
- 语法OK 零重复函数 零密钥泄露
This commit is contained in:
鉴影 ICE-GL-CA001 2026-07-10 14:48:56 +08:00
parent 922d977efd
commit 969ca660c0
8 changed files with 1964 additions and 70 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
# LIB · 成片合成模块
# D180 · 2026-07-10
"""FFmpeg: 多镜头拼接 + 音频合成 + 字幕叠加 → 导出成片"""
import os, subprocess, json
import os, subprocess, json, shutil
from lib.models import get_db
FFMPEG = os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe")
FFMPEG = shutil.which("ffmpeg") or os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe")
PROJECT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def concat_shots(episode_id, output_path=None, add_fade=True):
@ -41,16 +41,12 @@ def concat_shots(episode_id, output_path=None, add_fade=True):
else:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# 生成 ffmpeg concat 文件列表
# 生成 ffmpeg concat 文件列表(视频自带时长,无需手动指定 duration
concat_list = os.path.join(PROJECT, "outputs", "temp_concat.txt")
with open(concat_list, "w", encoding="utf-8") as f:
for s in shots:
abs_path = os.path.join(PROJECT, s["output_path"]).replace("\\", "/")
f.write(f"file '{abs_path}'\n")
f.write(f"duration {s['duration_sec']}\n")
# 最后一个文件需要重复一次ffmpeg concat 约定)
last = os.path.join(PROJECT, shots[-1]["output_path"]).replace("\\", "/")
f.write(f"file '{last}'\n")
# FFmpeg concat
cmd = [
@ -108,19 +104,22 @@ def add_audio_and_subtitle(video_path, audio_paths, subtitle_texts, output_path)
# Audio mix
if audio_inputs:
n = len(audio_inputs)
amix = "".join(audio_filters) + f"amix=inputs={n}:duration=first[aout]"
n = len(audio_paths)
amix = "".join(audio_filters) + f"{''.join([f'[a{i}]' for i in range(n)])}amix=inputs={n}:duration=first[aout]"
filter_complex = f"[0:v]{sub_filter}[vout];{amix}"
else:
amix = ""
filter_complex = f"[0:v]{sub_filter}[vout];{amix}" if audio_inputs else f"[0:v]{sub_filter}[vout]"
filter_complex = f"[0:v]{sub_filter}[vout]"
cmd = [FFMPEG, "-y", "-i", video_path] + audio_inputs + [
"-filter_complex", filter_complex,
"-map", "[vout]",
"-map", "[aout]" if audio_inputs else "-an",
]
if audio_inputs:
cmd += ["-map", "[aout]", "-c:a", "aac"]
else:
cmd += ["-an"]
cmd += [
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac",
"-pix_fmt", "yuv420p",
output_path
]
@ -143,6 +142,81 @@ def render_episode(episode_id):
print(f"📁 {result['output_path']}")
return result
def render_episode_full(episode_id, audio_config=None, subtitle_config=None, color_grade=None):
"""
完整成片拼接 调用 video-editor.js 做配音+BGM+字幕+调色+运镜 导出
这是对 engines/video-editor.js Python 封装
当需要完整成片不只是拼接时使用此函数
Args:
episode_id: 剧集ID
audio_config: {"voice": "path/to/voice.wav", "bgm": "path/to/bgm.mp3", "sfx": [...]}
subtitle_config: {"srt": "path/to/subtitles.srt"}
color_grade: {"brightness": 0, "contrast": 1.1, "saturation": 1.05}
"""
import json as _json
# Step 1: 先拼接所有已完成镜头
concat_result = concat_shots(episode_id)
if "error" in concat_result:
return concat_result
# Step 2: 构造 video-editor.js 的输入
editor_input = {
"timeline": [
{
"shot": concat_result["output_path"],
"transition": "cut"
}
],
"resolution": {"w": 1080, "h": 1920}, # 竖屏9:16
"fps": 24,
"output": concat_result["output_path"].replace("-final.mp4", "-final-full.mp4")
}
if audio_config:
editor_input["audio"] = audio_config
if subtitle_config:
editor_input["subtitle"] = subtitle_config.get("srt")
if color_grade:
editor_input["colorGrade"] = color_grade
# Step 3: 调用 video-editor.js
input_file = concat_result["output_path"] + ".editor-input.json"
engines_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "engines")
editor_js = os.path.join(engines_dir, "video-editor.js")
if not os.path.exists(editor_js):
print(f"⚠️ video-editor.js not found at {editor_js}, falling back to simple concat")
return concat_result
with open(input_file, "w", encoding="utf-8") as f:
_json.dump(editor_input, f, ensure_ascii=False, indent=2)
try:
r = subprocess.run(
["node", "-e", f"const ed=require('{editor_js.replace(chr(92),'/')}');"
f"const cfg=require('{input_file.replace(chr(92),'/')}');"
f"ed.edit(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=300
)
os.unlink(input_file)
if r.returncode == 0:
result = _json.loads(r.stdout)
return {
"output_path": result.get("outputPath", editor_input["output"]),
"shot_count": concat_result["shot_count"],
"total_duration": concat_result["total_duration"],
"editor_used": True
}
return {"error": r.stderr[:500], "fallback": concat_result}
except Exception as e:
if os.path.exists(input_file): os.unlink(input_file)
return {"error": str(e), "fallback": concat_result}
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:

View File

@ -6,7 +6,8 @@ import os
import subprocess
import tempfile
ARK_KEY = "ark-ddeba9f4-8c5a-449e-b549-9c29ec1e6f8c-a39ea"
from lib.secrets import get_ark_key
ARK_KEY = get_ark_key()
ARK_CHAT_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
# 可用模型

View File

@ -38,6 +38,8 @@ CREATE TABLE IF NOT EXISTS shots (
resolution TEXT DEFAULT '720p',
output_path TEXT,
seedance_task_id TEXT,
seed_value INTEGER DEFAULT 0,
lock_seed INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
error_log TEXT
@ -70,6 +72,8 @@ CREATE TABLE IF NOT EXISTS generation_tasks (
prompt_used TEXT,
references_used TEXT,
error_message TEXT,
cost_estimated REAL DEFAULT 0,
cost_actual REAL DEFAULT 0,
started_at TEXT,
completed_at TEXT,
created_at TEXT DEFAULT (datetime('now'))

View File

@ -0,0 +1,58 @@
# LIB · 密钥管理模块
# D181 · 2026-07-10
"""统一密钥入口 · 永不硬编码 · 铁律①"""
import os
def get_ark_key():
"""获取火山方舟 ARK API Key
查找顺序:
1. 环境变量 ARK_API_KEY
2. tools/secrets_loader.py SC-001
3. 项目根 .env 文件
"""
# 1. 直接环境变量
key = os.environ.get("ARK_API_KEY")
if key:
return key
# 2. 通过 secrets_loader 的编号体系
try:
import sys
tools_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"tools"
)
if tools_dir not in sys.path:
sys.path.insert(0, tools_dir)
from secrets_loader import secret
key = secret("SC-001")
if key:
return key
except Exception:
pass
# 3. .env 文件兜底
for env_path in [
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env"),
os.path.expanduser("~/guanghulab/video-ai-system/.env"),
]:
if os.path.exists(env_path):
try:
with open(env_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
if k.strip() == "ARK_API_KEY":
return v.strip()
except Exception:
continue
raise RuntimeError(
"🔑 ARK_API_KEY 未找到。\n"
" 请设置环境变量: export ARK_API_KEY=ark-xxx\n"
" 或配置 SC-001: tools/secrets_loader.py\n"
" 或在项目根目录创建 .env 文件: ARK_API_KEY=ark-xxx\n"
" ⊢ 铁律① · 密钥永远不放代码仓库"
)

View File

@ -3,11 +3,15 @@
"""火山方舟 ARK → Seedance 2.0 · 提交+轮询+下载+写库 全自动"""
import json, os, subprocess, time, tempfile, base64
ARK_KEY = "ark-ddeba9f4-8c5a-449e-b549-9c29ec1e6f8c-a39ea"
from lib.secrets import get_ark_key
ARK_KEY = get_ark_key()
ARK_BASE = "https://ark.cn-beijing.volces.com/api/v3"
SEEDANCE_MODEL = "doubao-seedance-2-0-260128"
TASK_URL = f"{ARK_BASE}/contents/generations/tasks"
# 成本估算(元/秒·720p· 参考价,实际以火山方舟计费为准
COST_PER_SECOND = {"720p": 0.5, "1080p": 1.0}
def _curl(method, url, payload=None):
"""通用 curl 调用"""
tf = None
@ -108,13 +112,14 @@ def generate_shot_async(shot_id, prompt, references, duration=6, output_dir="out
task_id = submit_task(prompt, references, duration)
if isinstance(task_id, dict):
return task_id
# 写入任务记录
# 写入任务记录(含成本估算)
from lib.models import get_db
db = get_db()
estimated_cost = round(duration * COST_PER_SECOND.get("720p", 0.5), 2)
db.execute("""
INSERT INTO generation_tasks (id, shot_id, task_type, seedance_task_id, status, prompt_used, references_used, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
""", (task_id[:20], shot_id, "video", task_id, "running", prompt, json.dumps([r[0] for r in references] if references else [])))
INSERT INTO generation_tasks (id, shot_id, task_type, seedance_task_id, status, prompt_used, references_used, cost_estimated, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
""", (task_id[:20], shot_id, "video", task_id, "running", prompt, json.dumps([r[0] for r in references] if references else []), estimated_cost))
db.execute("UPDATE shots SET seedance_task_id = ?, status = 'generating' WHERE id = ?", (task_id, shot_id))
db.commit()
return {"task_id": task_id, "status": "submitted"}
@ -131,8 +136,11 @@ def check_and_collect(shot_id):
result = poll_task(task["seedance_task_id"], poll_interval=0, max_wait=1)
if "error" in result:
return result
if "video_url" not in str(result):
status = result.get("status", "")
if status in ("running", "queued", "processing"):
return {"status": "running", "task_id": task["seedance_task_id"]}
if status != "succeeded":
return {"error": f"Task {status}: {str(result)[:200]}"}
# Succeeded - download
video_url = result.get("content", {}).get("video_url")
@ -141,9 +149,16 @@ def check_and_collect(shot_id):
output_path = os.path.join(output_dir, f"{shot_id}.mp4")
if download_video(video_url, output_path):
db.execute("""
UPDATE generation_tasks SET status = 'succeeded', completed_at = datetime('now') WHERE id = ?
""", (task["id"],))
db.execute("UPDATE shots SET status = 'done', output_path = ? WHERE id = ?", (output_path, shot_id))
UPDATE generation_tasks SET status = 'succeeded', cost_actual = ?, completed_at = datetime('now') WHERE id = ?
""", (result.get("usage", {}).get("total_tokens", 0), task["id"],))
# 保存seed
seed = result.get("seed")
if seed:
db.execute("UPDATE shots SET status = 'done', output_path = ?, seed_value = ? WHERE id = ?",
(output_path, seed, shot_id))
else:
db.execute("UPDATE shots SET status = 'done', output_path = ? WHERE id = ?",
(output_path, shot_id))
db.commit()
return {"status": "done", "output_path": output_path, "seed": result.get("seed")}
return {"error": "download failed"}
@ -151,7 +166,11 @@ def check_and_collect(shot_id):
# ====== 批量并行生成 + 自动重试 ======
def batch_submit(episode_id, max_parallel=3, retry_failed=False):
"""批量提交:自动读库中所有 pending 镜头 → 并行提交 → 并行轮询"""
"""批量提交:自动读库中所有 pending 镜头 → 并行提交 → 并行轮询
支持 Ctrl+C 中断恢复已提交的任务 ID 都在数据库中
中断后用 batch_resume() 继续收集
"""
from lib.models import get_db
db = get_db()
@ -162,7 +181,8 @@ def batch_submit(episode_id, max_parallel=3, retry_failed=False):
print("⚠️ 没有待生成的镜头")
return
print(f"🚀 批量提交 {len(shots)} 个镜头(最多并行 {max_parallel}\n")
print(f"🚀 批量提交 {len(shots)} 个镜头(最多并行 {max_parallel}")
print(f"💡 可随时 Ctrl+C 中断,用 batch-resume 恢复\n")
results = {}
queue = list(shots)
@ -213,3 +233,49 @@ def batch_submit(episode_id, max_parallel=3, retry_failed=False):
failed = sum(1 for r in results.values() if r["status"] == "failed")
print(f"\n📊 批量完成: {done}{failed}❌ (共{len(results)})")
return results
def batch_resume(episode_id):
"""从中断恢复:读数据库中所有 generating 状态的镜头 → 继续轮询收集
适用于 batch_submit 中途 Ctrl+C 后恢复
已提交到 Seedance 的任务 ID 保存在 generation_tasks 表中不会丢失
"""
from lib.models import get_db
db = get_db()
tasks = db.execute("""
SELECT gt.shot_id, gt.seedance_task_id, s.shot_number
FROM generation_tasks gt
JOIN shots s ON gt.shot_id = s.id
WHERE s.episode_id = ? AND s.status = 'generating'
ORDER BY s.shot_number
""", (episode_id,)).fetchall()
if not tasks:
print("⚠️ 没有正在生成的镜头,无需恢复")
return
print(f"🔄 恢复 {len(tasks)} 个正在生成的镜头...\n")
results = {}
for task in tasks:
shot_id = task["shot_id"]
print(f"📥 检查 {shot_id} (task: {task['seedance_task_id'][:20]}...)")
result = check_and_collect(shot_id)
if result.get("status") == "done":
results[shot_id] = "done"
print(f" ✅ 已完成")
elif result.get("status") == "running":
results[shot_id] = "running"
print(f" 🔄 仍在生成中")
else:
results[shot_id] = "unknown"
print(f"{result}")
done = sum(1 for v in results.values() if v == "done")
running = sum(1 for v in results.values() if v == "running")
print(f"\n📊 恢复结果: {done}{running}🔄 (共{len(results)})")
if running > 0:
print(f"💡 还有 {running} 个镜头在生成中,稍后再次运行 batch-resume")
return results

View File

@ -6,7 +6,8 @@ import os
import urllib.request
import base64
ARK_KEY = "ark-ddeba9f4-8c5a-449e-b549-9c29ec1e6f8c-a39ea"
from lib.secrets import get_ark_key
ARK_KEY = get_ark_key()
ARK_BASE = "https://ark.cn-beijing.volces.com/api/v3"
SEEDREAM_MODEL = "doubao-seedream-4-0-250828"

View File

@ -1,17 +1,28 @@
# LIB · TTS 配音模块 + 自动字幕生成
# D180 · 2026-07-10
"""Edge-TTS 配音引擎 · 多角色音色 · 自动SRT字幕"""
import asyncio, edge_tts, os, json
import asyncio, edge_tts, os, json, shutil
# 角色音色表
VOICES = {
"林昊": "zh-CN-YunxiNeural", # 青年男声·迷茫虚弱
"旁白": "zh-CN-YunyangNeural", # 新闻男声
"默认女": "zh-CN-XiaoxiaoNeural", # 女声
"默认男": "zh-CN-YunxiNeural",
}
# 角色音色表 · 从 engines/tts-engine.py 加载(单一真相源)
try:
import sys, os as _os
_engine_dir = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), "engines")
if _engine_dir not in sys.path: sys.path.insert(0, _engine_dir)
from tts_engine import DEFAULT_VOICES as _ENGINE_VOICES
VOICES = {name: cfg["voice"] for name, cfg in _ENGINE_VOICES.items()}
except Exception:
VOICES = {
"苏白": "zh-CN-XiaoxiaoNeural",
"诸葛风": "zh-CN-YunxiNeural",
"萧灵汐": "zh-CN-XiaoyiNeural",
"王执事": "zh-CN-YunyangNeural",
"林昊": "zh-CN-YunxiNeural",
"旁白": "zh-CN-YunyangNeural",
"默认女": "zh-CN-XiaoxiaoNeural",
"默认男": "zh-CN-YunxiNeural",
}
FFMPEG = os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe")
FFMPEG = shutil.which("ffmpeg") or os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe")
def tts(text, output_path, voice="zh-CN-YunxiNeural", rate="+0%"):
"""生成语音 mp3返回时间戳用于字幕"""