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:
parent
922d977efd
commit
969ca660c0
File diff suppressed because it is too large
Load Diff
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
# 可用模型
|
||||
|
||||
@ -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'))
|
||||
|
||||
58
video-ai-system/lib/secrets.py
Normal file
58
video-ai-system/lib/secrets.py
Normal 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"
|
||||
" ⊢ 铁律① · 密钥永远不放代码仓库"
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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(返回时间戳用于字幕)"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user