diff --git a/video-ai-system/lib/cli.py b/video-ai-system/lib/cli.py new file mode 100644 index 0000000..fc9f081 --- /dev/null +++ b/video-ai-system/lib/cli.py @@ -0,0 +1,248 @@ +# LIB · 蛋蛋短剧生产管线 · 命令行入口 +# D180 · 2026-07-10 +# Usage: python -m lib.cli [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 [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]} ") diff --git a/video-ai-system/lib/doubao_chat.py b/video-ai-system/lib/doubao_chat.py new file mode 100644 index 0000000..1cb665d --- /dev/null +++ b/video-ai-system/lib/doubao_chat.py @@ -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", # 深度思���·适合拆解 +} + +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 [args]") + print(" chat 直接对话") + print(" breakdown 拆解剧本文件") + 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) diff --git a/video-ai-system/lib/models.py b/video-ai-system/lib/models.py new file mode 100644 index 0000000..e3d183f --- /dev/null +++ b/video-ai-system/lib/models.py @@ -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() diff --git a/video-ai-system/lib/seedance.py b/video-ai-system/lib/seedance.py new file mode 100644 index 0000000..754b22b --- /dev/null +++ b/video-ai-system/lib/seedance.py @@ -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"} diff --git a/video-ai-system/lib/seedream.py b/video-ai-system/lib/seedream.py new file mode 100644 index 0000000..491907b --- /dev/null +++ b/video-ai-system/lib/seedream.py @@ -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 [output]") + print(" python seedream.py i2i [output]") + print(" python seedream.py multi [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')}") diff --git a/video-ai-system/lib/tts.py b/video-ai-system/lib/tts.py new file mode 100644 index 0000000..637f878 --- /dev/null +++ b/video-ai-system/lib/tts.py @@ -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 [voice]") + print(" python tts.py char ") + print(" python tts.py mix