D180 · LIB v1.0 · 自研短剧生产管线 · 六模块(DB/CLI/Seedream/豆包拆解/Seedance/TTS+FFmpeg) + D179首帧锚定法经验
This commit is contained in:
parent
3cc6fc0f0f
commit
2cd1e39304
248
video-ai-system/lib/cli.py
Normal file
248
video-ai-system/lib/cli.py
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
# LIB · 蛋蛋短剧生产管线 · 命令行入口
|
||||||
|
# D180 · 2026-07-10
|
||||||
|
# Usage: python -m lib.cli <command> [args]
|
||||||
|
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_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}")
|
||||||
|
|
||||||
|
COMMANDS = {
|
||||||
|
"init": cmd_init,
|
||||||
|
"shots": cmd_shot_list,
|
||||||
|
"shot": cmd_shot_status,
|
||||||
|
"assets": cmd_asset_list,
|
||||||
|
"sync-shots": cmd_sync_shots,
|
||||||
|
"link": cmd_link_asset,
|
||||||
|
"tasks": cmd_task_log,
|
||||||
|
"exp-add": cmd_exp_add,
|
||||||
|
"exp-list": cmd_exp_list,
|
||||||
|
"chat": cmd_chat,
|
||||||
|
"breakdown": cmd_breakdown,
|
||||||
|
"gen": cmd_generate,
|
||||||
|
"collect": cmd_collect,
|
||||||
|
}
|
||||||
|
|
||||||
|
def cmd_generate(shot_id):
|
||||||
|
"""提交 Seedance 生成任务(异步)"""
|
||||||
|
from lib.seedance import generate_shot_async
|
||||||
|
from lib.models import get_db
|
||||||
|
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} 已完成,需爸爸确认是否重跑")
|
||||||
|
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
|
||||||
|
|
||||||
|
r = generate_shot_async(shot_id, prompt, ref_paths, int(shot["duration_sec"] or 6))
|
||||||
|
if "error" in r:
|
||||||
|
print(f"❌ {r['error']}")
|
||||||
|
else:
|
||||||
|
print(f"📤 {shot_id} 已提交 · task_id: {r['task_id'][:20]}...")
|
||||||
|
print(f" 轮询: python -m lib.cli collect {shot_id}")
|
||||||
|
|
||||||
|
def cmd_collect(shot_id):
|
||||||
|
"""收集中间结果(检查+下载)"""
|
||||||
|
from lib.seedance import check_and_collect
|
||||||
|
r = check_and_collect(shot_id)
|
||||||
|
if r.get("error"):
|
||||||
|
print(f"❌ {r['error']}")
|
||||||
|
elif r.get("status") == "done":
|
||||||
|
print(f"✅ {shot_id} 完成 → {r['output_path']}")
|
||||||
|
elif r.get("status") == "running":
|
||||||
|
print(f"🔄 {shot_id} 生成中(ID: {r['task_id'][:20]}...)")
|
||||||
|
else:
|
||||||
|
print(f"❓ {r}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
||||||
|
print("LIB · 蛋蛋短剧生产管线 · D180")
|
||||||
|
print("Usage: python -m lib.cli <command> [args]")
|
||||||
|
print(f"Commands: {', '.join(COMMANDS.keys())}")
|
||||||
|
sys.exit(1)
|
||||||
|
cmd = COMMANDS[sys.argv[1]]
|
||||||
|
args = sys.argv[2:]
|
||||||
|
try:
|
||||||
|
cmd(*args)
|
||||||
|
except TypeError as e:
|
||||||
|
print(f"参数错误: {e}")
|
||||||
|
print(f"Usage: python -m lib.cli {sys.argv[1]} <args>")
|
||||||
123
video-ai-system/lib/doubao_chat.py
Normal file
123
video-ai-system/lib/doubao_chat.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
# LIB · 豆包对话模型适配器(剧本拆解/分镜生成)
|
||||||
|
# D180 · 2026-07-10
|
||||||
|
"""火山方舟 ARK Chat API → doubao-seed 系列模型"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ARK_KEY = "ark-ddeba9f4-8c5a-449e-b549-9c29ec1e6f8c-a39ea"
|
||||||
|
ARK_CHAT_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||||
|
|
||||||
|
# 可用模型
|
||||||
|
MODELS = {
|
||||||
|
"pro": "doubao-seed-2-1-pro-260628", # 最强·适合复杂剧本
|
||||||
|
"lite": "doubao-seed-2-0-lite-260215", # 轻量·适合批量
|
||||||
|
"deepseek": "deepseek-v4-pro-260425", # 深度思<E5BAA6><E6809D><EFBFBD>·适合拆解
|
||||||
|
}
|
||||||
|
|
||||||
|
def _curl_api(payload):
|
||||||
|
"""通过 subprocess curl 调用 API(绕过 Windows urllib 超时问题)"""
|
||||||
|
import subprocess, tempfile
|
||||||
|
tf = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8')
|
||||||
|
json.dump(payload, tf, ensure_ascii=False)
|
||||||
|
tf.close()
|
||||||
|
try:
|
||||||
|
result = subprocess.run([
|
||||||
|
"curl", "-s", ARK_CHAT_URL,
|
||||||
|
"-H", f"Authorization: Bearer {ARK_KEY}",
|
||||||
|
"-H", "Content-Type: application/json",
|
||||||
|
"-d", f"@{tf.name}"
|
||||||
|
], capture_output=True, text=True, timeout=120)
|
||||||
|
os.unlink(tf.name)
|
||||||
|
return json.loads(result.stdout) if result.stdout else {"error": result.stderr}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.unlink(tf.name)
|
||||||
|
return {"error": "timeout"}
|
||||||
|
|
||||||
|
def chat(prompt, system="你是一个专业的短剧剧本分析专家", model="pro", temperature=0.3, max_tokens=4096):
|
||||||
|
"""调用豆包对话模型"""
|
||||||
|
payload = {
|
||||||
|
"model": MODELS.get(model, model),
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens
|
||||||
|
}
|
||||||
|
result = _curl_api(payload)
|
||||||
|
if "error" in result:
|
||||||
|
return result
|
||||||
|
choice = result.get("choices", [{}])[0]
|
||||||
|
return {
|
||||||
|
"content": choice.get("message", {}).get("content", ""),
|
||||||
|
"model": result.get("model", ""),
|
||||||
|
"tokens": result.get("usage", {}),
|
||||||
|
"finish": choice.get("finish_reason", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
def breakdown_script(script_text, episode_num=1):
|
||||||
|
"""拆解一集剧本为结构化分镜"""
|
||||||
|
prompt = f"""请将以下短剧剧本第{episode_num}集拆解为结构化分镜。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 按镜头拆分,每个镜头包含:镜头编号、景别(特写/近景/中景/全景/POV)、时长(秒)
|
||||||
|
2. 提取每个镜头中出现的角色、场景、道具
|
||||||
|
3. 写出每个镜头的画面描述(50字以内)
|
||||||
|
4. 标注镜头类型:establishing/action/reaction/closeup/transition
|
||||||
|
|
||||||
|
输出JSON格式:
|
||||||
|
{{
|
||||||
|
"episode": {episode_num},
|
||||||
|
"shots": [
|
||||||
|
{{
|
||||||
|
"shot_number": "S01",
|
||||||
|
"description": "画面描述",
|
||||||
|
"camera": "POV|特写|近景|中景|全景",
|
||||||
|
"duration": 6,
|
||||||
|
"type": "establishing|action|reaction|closeup|transition",
|
||||||
|
"characters": ["角色名"],
|
||||||
|
"scenes": ["场景名"],
|
||||||
|
"props": ["道具名"],
|
||||||
|
"dialogue": null
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
剧本内容:
|
||||||
|
{script_text}"""
|
||||||
|
return chat(prompt, system="你是专业的短剧剧本拆解专家,输出纯JSON,不添加任何解释。")
|
||||||
|
|
||||||
|
def generate_keyframes(shot_description, character_refs, scene_refs):
|
||||||
|
"""为单个镜头生成关键帧 prompt"""
|
||||||
|
prompt = f"""基于以下镜头描述,生成即梦4.0图像生成提示词。
|
||||||
|
|
||||||
|
镜头:{shot_description}
|
||||||
|
可用角色:{json.dumps(character_refs, ensure_ascii=False)}
|
||||||
|
可用场景:{json.dumps(scene_refs, ensure_ascii=False)}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 提示词用英文
|
||||||
|
2. 包含景别、角色位置、场景细节、光照、风格
|
||||||
|
3. 不超过150词
|
||||||
|
4. 输出纯提示词,不加任何解释"""
|
||||||
|
return chat(prompt, model="pro", temperature=0.5, max_tokens=300)
|
||||||
|
|
||||||
|
# ====== CLI ======
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python doubao_chat.py <command> [args]")
|
||||||
|
print(" chat <prompt> 直接对话")
|
||||||
|
print(" breakdown <file> 拆解剧本文件")
|
||||||
|
sys.exit(1)
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == "chat":
|
||||||
|
r = chat(sys.argv[2])
|
||||||
|
print(r["content"] if "content" in r else r)
|
||||||
|
elif cmd == "breakdown":
|
||||||
|
with open(sys.argv[2], "r", encoding="utf-8") as f:
|
||||||
|
script = f.read()
|
||||||
|
r = breakdown_script(script)
|
||||||
|
print(r["content"] if "content" in r else r)
|
||||||
109
video-ai-system/lib/models.py
Normal file
109
video-ai-system/lib/models.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# LIB · 蛋蛋短剧生产管线 · 数据库模型
|
||||||
|
# D180 · 2026-07-10
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "lib.db")
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
number INTEGER NOT NULL,
|
||||||
|
script_path TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
episode_id TEXT NOT NULL REFERENCES episodes(id),
|
||||||
|
shot_number TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
script_ref TEXT,
|
||||||
|
camera TEXT,
|
||||||
|
duration_sec REAL,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
prompt TEXT,
|
||||||
|
model TEXT DEFAULT 'doubao-seedance-2-0-260128',
|
||||||
|
resolution TEXT DEFAULT '720p',
|
||||||
|
output_path TEXT,
|
||||||
|
seedance_task_id TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
error_log TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('CHAR','ENV','PROP','MASTER')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
approved_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shot_assets (
|
||||||
|
shot_id TEXT NOT NULL REFERENCES shots(id),
|
||||||
|
asset_id TEXT NOT NULL REFERENCES assets(id),
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('reference','character','scene','prop','master')),
|
||||||
|
PRIMARY KEY (shot_id, asset_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
shot_id TEXT NOT NULL REFERENCES shots(id),
|
||||||
|
task_type TEXT NOT NULL CHECK(task_type IN ('image','video','master_frame')),
|
||||||
|
seedance_task_id TEXT,
|
||||||
|
status TEXT DEFAULT 'submitted',
|
||||||
|
prompt_used TEXT,
|
||||||
|
references_used TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
started_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS experience_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
tags TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = sqlite3.connect(DB_PATH)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
db.executescript(SCHEMA)
|
||||||
|
db.commit()
|
||||||
|
return db
|
||||||
|
|
||||||
|
def init():
|
||||||
|
db = get_db()
|
||||||
|
# Create default project and episode if not exist
|
||||||
|
db.execute("INSERT OR IGNORE INTO projects (id, name) VALUES (?, ?)",
|
||||||
|
("deep-sea-voyage", "深海迷航"))
|
||||||
|
db.execute("""INSERT OR IGNORE INTO episodes (id, project_id, number, script_path, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
("DSV-EP01", "deep-sea-voyage", 1,
|
||||||
|
"projects/deep-sea-voyage/script/EP01-SCRIPT-LOCK.hdlp", "ready"))
|
||||||
|
db.commit()
|
||||||
|
print(f"LIB initialized: {DB_PATH}")
|
||||||
|
return db
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init()
|
||||||
149
video-ai-system/lib/seedance.py
Normal file
149
video-ai-system/lib/seedance.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# LIB · Seedance 2.0 视频生成适配器 + 异步任务队列
|
||||||
|
# D180 · 2026-07-10
|
||||||
|
"""火山方舟 ARK → Seedance 2.0 · 提交+轮询+下载+写库 全自动"""
|
||||||
|
import json, os, subprocess, time, tempfile, base64
|
||||||
|
|
||||||
|
ARK_KEY = "ark-ddeba9f4-8c5a-449e-b549-9c29ec1e6f8c-a39ea"
|
||||||
|
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"
|
||||||
|
|
||||||
|
def _curl(method, url, payload=None):
|
||||||
|
"""通用 curl 调用"""
|
||||||
|
tf = None
|
||||||
|
cmd = ["curl", "-s", url, "-H", f"Authorization: Bearer {ARK_KEY}"]
|
||||||
|
if payload:
|
||||||
|
tf = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8')
|
||||||
|
json.dump(payload, tf, ensure_ascii=False)
|
||||||
|
tf.close()
|
||||||
|
cmd += ["-H", "Content-Type: application/json", "-d", f"@{tf.name}"]
|
||||||
|
if method == "POST":
|
||||||
|
cmd.insert(1, "-X")
|
||||||
|
cmd.insert(2, "POST")
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
if tf: os.unlink(tf.name)
|
||||||
|
return json.loads(r.stdout) if r.stdout else {"error": r.stderr}
|
||||||
|
except Exception as e:
|
||||||
|
if tf: os.unlink(tf.name)
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
def submit_task(prompt, references=None, duration=6, resolution="720p"):
|
||||||
|
"""提交 Seedance 生成任务 → 返回 task_id"""
|
||||||
|
content = [{"type": "text", "text": prompt}]
|
||||||
|
if references:
|
||||||
|
for ref_type, ref_path in references:
|
||||||
|
with open(ref_path, "rb") as f:
|
||||||
|
fmt = "png" if ref_path.endswith(".png") else "jpeg"
|
||||||
|
b64 = base64.b64encode(f.read()).decode()
|
||||||
|
content.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"role": ref_type,
|
||||||
|
"image_url": {"url": f"data:image/{fmt};base64,{b64}"}
|
||||||
|
})
|
||||||
|
payload = {
|
||||||
|
"model": SEEDANCE_MODEL,
|
||||||
|
"content": content,
|
||||||
|
"duration": duration,
|
||||||
|
"resolution": resolution
|
||||||
|
}
|
||||||
|
result = _curl("POST", TASK_URL, payload)
|
||||||
|
if "id" in result:
|
||||||
|
return result["id"]
|
||||||
|
return {"error": result}
|
||||||
|
|
||||||
|
def poll_task(task_id, poll_interval=30, max_wait=600):
|
||||||
|
"""轮询任务状态 → 返回结果"""
|
||||||
|
elapsed = 0
|
||||||
|
while elapsed < max_wait:
|
||||||
|
result = _curl("GET", f"{TASK_URL}/{task_id}")
|
||||||
|
if "error" in result:
|
||||||
|
return result
|
||||||
|
status = result.get("status", "")
|
||||||
|
if status == "succeeded":
|
||||||
|
return result
|
||||||
|
if status == "failed":
|
||||||
|
return {"error": f"Task failed: {result}"}
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
elapsed += poll_interval
|
||||||
|
return {"error": "timeout"}
|
||||||
|
|
||||||
|
def download_video(video_url, output_path):
|
||||||
|
"""下载视频"""
|
||||||
|
subprocess.run(["curl", "-s", "-o", output_path, video_url], check=False)
|
||||||
|
return os.path.exists(output_path) and os.path.getsize(output_path) > 0
|
||||||
|
|
||||||
|
def generate_shot(shot_id, prompt, references, duration=6, output_dir="outputs/videos"):
|
||||||
|
"""一站式生成:提交→轮询→下载→返回结果"""
|
||||||
|
print(f"[LIB] 提交 {shot_id}...")
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
task_id = submit_task(prompt, references, duration)
|
||||||
|
if isinstance(task_id, dict):
|
||||||
|
return {"error": task_id}
|
||||||
|
|
||||||
|
print(f"[LIB] 任务 {task_id[:20]}... 等待完成")
|
||||||
|
result = poll_task(task_id)
|
||||||
|
if "error" in result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
video_url = result.get("content", {}).get("video_url")
|
||||||
|
if not video_url:
|
||||||
|
return {"error": "no video_url"}
|
||||||
|
|
||||||
|
output_path = os.path.join(output_dir, f"{shot_id}.mp4")
|
||||||
|
print(f"[LIB] 下载视频...")
|
||||||
|
if download_video(video_url, output_path):
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"output_path": output_path,
|
||||||
|
"seed": result.get("seed"),
|
||||||
|
"duration": result.get("duration"),
|
||||||
|
"resolution": result.get("resolution"),
|
||||||
|
"tokens": result.get("usage", {}).get("total_tokens", 0)
|
||||||
|
}
|
||||||
|
return {"error": "download failed"}
|
||||||
|
|
||||||
|
def generate_shot_async(shot_id, prompt, references, duration=6, output_dir="outputs/videos"):
|
||||||
|
"""异步提交:只提交不等待,返回 task_id 用于后续轮询"""
|
||||||
|
task_id = submit_task(prompt, references, duration)
|
||||||
|
if isinstance(task_id, dict):
|
||||||
|
return task_id
|
||||||
|
# 写入任务记录
|
||||||
|
from lib.models import get_db
|
||||||
|
db = get_db()
|
||||||
|
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 [])))
|
||||||
|
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"}
|
||||||
|
|
||||||
|
def check_and_collect(shot_id):
|
||||||
|
"""检查任务状态,完成后自动下载"""
|
||||||
|
from lib.models import get_db
|
||||||
|
db = get_db()
|
||||||
|
task = db.execute("""
|
||||||
|
SELECT * FROM generation_tasks WHERE shot_id = ? ORDER BY created_at DESC LIMIT 1
|
||||||
|
""", (shot_id,)).fetchone()
|
||||||
|
if not task:
|
||||||
|
return {"error": "no task found"}
|
||||||
|
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):
|
||||||
|
return {"status": "running", "task_id": task["seedance_task_id"]}
|
||||||
|
|
||||||
|
# Succeeded - download
|
||||||
|
video_url = result.get("content", {}).get("video_url")
|
||||||
|
output_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "outputs", "videos")
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
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))
|
||||||
|
db.commit()
|
||||||
|
return {"status": "done", "output_path": output_path, "seed": result.get("seed")}
|
||||||
|
return {"error": "download failed"}
|
||||||
104
video-ai-system/lib/seedream.py
Normal file
104
video-ai-system/lib/seedream.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# LIB · 即梦4.0 (Seedream 4.0) 适配器
|
||||||
|
# D180 · 2026-07-10
|
||||||
|
"""火山方舟 ARK API → Seedream 4.0 图像生成"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
import base64
|
||||||
|
|
||||||
|
ARK_KEY = "ark-ddeba9f4-8c5a-449e-b549-9c29ec1e6f8c-a39ea"
|
||||||
|
ARK_BASE = "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
SEEDREAM_MODEL = "doubao-seedream-4-0-250828"
|
||||||
|
|
||||||
|
def _api_call(endpoint, payload):
|
||||||
|
"""通用 ARK API 调用"""
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{ARK_BASE}{endpoint}",
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {ARK_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
method="POST"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
err_body = e.read().decode()
|
||||||
|
return {"error": str(e.code), "detail": err_body}
|
||||||
|
|
||||||
|
def seedream_t2i(prompt, size="1440x2560", n=1):
|
||||||
|
"""文生图"""
|
||||||
|
payload = {
|
||||||
|
"model": SEEDREAM_MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"size": size,
|
||||||
|
"n": n
|
||||||
|
}
|
||||||
|
return _api_call("/images/generations", payload)
|
||||||
|
|
||||||
|
def seedream_i2i(prompt, image_path, size="1440x2560", n=1):
|
||||||
|
"""图生图"""
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
img_b64 = base64.b64encode(f.read()).decode()
|
||||||
|
payload = {
|
||||||
|
"model": SEEDREAM_MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"image": f"data:image/jpeg;base64,{img_b64}",
|
||||||
|
"size": size,
|
||||||
|
"n": n
|
||||||
|
}
|
||||||
|
return _api_call("/images/generations", payload)
|
||||||
|
|
||||||
|
def seedream_multi(prompt, image_paths, size="1440x2560", n=1):
|
||||||
|
"""多图融合"""
|
||||||
|
imgs = []
|
||||||
|
for p in image_paths:
|
||||||
|
with open(p, "rb") as f:
|
||||||
|
fmt = "png" if p.endswith(".png") else "jpeg"
|
||||||
|
imgs.append(f"data:image/{fmt};base64,{base64.b64encode(f.read()).decode()}")
|
||||||
|
payload = {
|
||||||
|
"model": SEEDREAM_MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"image": imgs,
|
||||||
|
"size": size,
|
||||||
|
"n": n
|
||||||
|
}
|
||||||
|
return _api_call("/images/generations", payload)
|
||||||
|
|
||||||
|
def seedream_download(result, output_path):
|
||||||
|
"""从 API 结果下载图片"""
|
||||||
|
if "error" in result:
|
||||||
|
return None
|
||||||
|
url = result.get("data", [{}])[0].get("url")
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
urllib.request.urlretrieve(url, output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
# ====== CLI ======
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python seedream.py t2i <prompt> [output]")
|
||||||
|
print(" python seedream.py i2i <prompt> <image_path> [output]")
|
||||||
|
print(" python seedream.py multi <prompt> <img1> <img2> [output]")
|
||||||
|
sys.exit(1)
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == "t2i":
|
||||||
|
prompt = sys.argv[2]
|
||||||
|
out = sys.argv[3] if len(sys.argv) > 3 else "seedream_output.jpg"
|
||||||
|
r = seedream_t2i(prompt)
|
||||||
|
p = seedream_download(r, out)
|
||||||
|
print(f"✅ {p}" if p else f"❌ {r.get('error','unknown')}")
|
||||||
|
elif cmd == "i2i":
|
||||||
|
prompt, img, out = sys.argv[2], sys.argv[3], (sys.argv[4] if len(sys.argv) > 4 else "seedream_i2i_output.jpg")
|
||||||
|
r = seedream_i2i(prompt, img)
|
||||||
|
p = seedream_download(r, out)
|
||||||
|
print(f"✅ {p}" if p else f"❌ {r.get('error','unknown')}")
|
||||||
|
elif cmd == "multi":
|
||||||
|
prompt, imgs, out = sys.argv[2], sys.argv[3:-1], sys.argv[-1]
|
||||||
|
r = seedream_multi(prompt, imgs)
|
||||||
|
p = seedream_download(r, out)
|
||||||
|
print(f"✅ {p}" if p else f"❌ {r.get('error','unknown')}")
|
||||||
74
video-ai-system/lib/tts.py
Normal file
74
video-ai-system/lib/tts.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# LIB · TTS 配音模块
|
||||||
|
# D180 · 2026-07-10
|
||||||
|
"""Edge-TTS 配音引擎 · 多角色音色"""
|
||||||
|
import asyncio, edge_tts, os
|
||||||
|
|
||||||
|
# 角色音色表
|
||||||
|
VOICES = {
|
||||||
|
"林昊": "zh-CN-YunxiNeural", # 青年男声·迷茫虚弱
|
||||||
|
"旁白": "zh-CN-YunyangNeural", # 新闻男声
|
||||||
|
"默认女": "zh-CN-XiaoxiaoNeural", # 女声
|
||||||
|
"默认男": "zh-CN-YunxiNeural",
|
||||||
|
}
|
||||||
|
|
||||||
|
FFMPEG = os.path.expanduser("~/.workbuddy/binaries/ffmpeg.exe")
|
||||||
|
|
||||||
|
def tts(text, output_path, voice="zh-CN-YunxiNeural", rate="+0%"):
|
||||||
|
"""生成语音 mp3"""
|
||||||
|
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||||
|
async def _run():
|
||||||
|
await edge_tts.Communicate(text, voice, rate=rate).save(output_path)
|
||||||
|
asyncio.run(_run())
|
||||||
|
return output_path if os.path.exists(output_path) else None
|
||||||
|
|
||||||
|
def tts_for_character(text, character, output_path, rate="+0%"):
|
||||||
|
"""按角色名查音色生成"""
|
||||||
|
voice = VOICES.get(character, "zh-CN-YunxiNeural")
|
||||||
|
return tts(text, output_path, voice, rate)
|
||||||
|
|
||||||
|
def mix_audio_video(video_path, audio_path, output_path, video_vol=0.3):
|
||||||
|
"""FFmpeg 合成视频+音频"""
|
||||||
|
if not os.path.exists(FFMPEG):
|
||||||
|
return f"ffmpeg not found at {FFMPEG}"
|
||||||
|
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||||
|
import subprocess
|
||||||
|
r = subprocess.run([
|
||||||
|
FFMPEG, "-y",
|
||||||
|
"-i", video_path,
|
||||||
|
"-i", audio_path,
|
||||||
|
"-filter_complex", f"[0:a]volume={video_vol}[va];[1:a]volume=1.0[aa];[va][aa]amix=inputs=2:duration=first",
|
||||||
|
"-c:v", "copy",
|
||||||
|
output_path
|
||||||
|
], capture_output=True, text=True)
|
||||||
|
return output_path if r.returncode == 0 else r.stderr
|
||||||
|
|
||||||
|
def add_subtitle(video_path, subtitle_text, output_path):
|
||||||
|
"""FFmpeg 添加字幕"""
|
||||||
|
if not os.path.exists(FFMPEG):
|
||||||
|
return f"ffmpeg not found at {FFMPEG}"
|
||||||
|
import subprocess
|
||||||
|
r = subprocess.run([
|
||||||
|
FFMPEG, "-y",
|
||||||
|
"-i", video_path,
|
||||||
|
"-vf", f"drawtext=text='{subtitle_text}':fontsize=28:fontcolor=white:borderw=2:bordercolor=black:x=(w-text_w)/2:y=h-120",
|
||||||
|
"-c:a", "copy",
|
||||||
|
output_path
|
||||||
|
], capture_output=True, text=True)
|
||||||
|
return output_path if r.returncode == 0 else r.stderr
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python tts.py tts <text> <output> [voice]")
|
||||||
|
print(" python tts.py char <text> <character> <output>")
|
||||||
|
print(" python tts.py mix <video> <audio> <output>")
|
||||||
|
print(f"\nVoices: {list(VOICES.keys())}")
|
||||||
|
sys.exit(1)
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == "tts":
|
||||||
|
voice = sys.argv[4] if len(sys.argv) > 4 else "zh-CN-YunxiNeural"
|
||||||
|
print(tts(sys.argv[2], sys.argv[3], voice) or "FAILED")
|
||||||
|
elif cmd == "char":
|
||||||
|
print(tts_for_character(sys.argv[2], sys.argv[3], sys.argv[4]) or "FAILED")
|
||||||
|
elif cmd == "mix":
|
||||||
|
print(mix_audio_video(sys.argv[2], sys.argv[3], sys.argv[4]) or "FAILED")
|
||||||
Loading…
x
Reference in New Issue
Block a user