#!/usr/bin/env python3 """ agent_short_drama.py — 苍耳 · 一键短剧 Agent 剧本 → 分镜(豆包) → 路由(豆包) → 逐镜出图(本地Z-Image) → 逐镜视频(LTX I2V, 本地) → 拼接(FFmpeg) → 成片 用法: python agent_short_drama.py <剧本> -e 1 --until render # 只到出图 python agent_short_drama.py <剧本> -e 1 --until video # 到视频 python agent_short_drama.py <剧本> -e 1 --until compose # 到成片 python agent_short_drama.py <分镜.json> --from render # 从出图续跑 python agent_short_drama.py --from video # 从视频续跑 """ import os, sys, json, time, subprocess, urllib.request, argparse, glob, shutil COMFY = "http://127.0.0.1:8188" VIDEO_AI = os.path.dirname(os.path.abspath(__file__)) COMFY_OUT = os.path.expanduser("~/comfy/ComfyUI/output") COMFY_IN = os.path.expanduser("~/comfy/ComfyUI/input") LTX_NEG = ("static, frozen, no movement, flickering, jittery, choppy motion, morphing, " "deformed, twisted, contorted, distorted, blurry, low resolution, extra limbs, " "unnatural body, ugly, bad anatomy, disfigured, malformed, warped spine") # ---------- 工具 ---------- def run_script(script, args): cmd = [sys.executable, os.path.join(VIDEO_AI, script)] + args print(" $", " ".join(cmd)) r = subprocess.run(cmd) if r.returncode != 0: raise RuntimeError(f"{script} 退出码 {r.returncode}") def comfy_submit(prompt): req = urllib.request.Request(f"{COMFY}/prompt", data=json.dumps({"prompt": prompt}).encode(), headers={"Content-Type": "application/json"}) return json.load(urllib.request.urlopen(req, timeout=15))["prompt_id"] def comfy_wait(pid, timeout=900): t0 = time.time() while time.time() - t0 < timeout: time.sleep(3) try: h = json.load(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=8)) except Exception: continue if pid in h and h[pid].get("outputs"): return h[pid]["outputs"] raise TimeoutError(f"ComfyUI 任务 {pid} 超时") def wait_file(path, timeout=60): t0 = time.time() while time.time() - t0 < timeout: if os.path.isfile(path) and os.path.getsize(path) > 0: return True time.sleep(2) return False # ---------- Z-Image 出图 ---------- def zimage_workflow(prompt, seed, prefix, width=1024, height=1024): return { "1": {"class_type": "UNETLoader", "inputs": {"unet_name": "z_image_turbo_bf16.safetensors", "weight_dtype": "default"}}, "2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "lumina2", "device": "default"}}, "3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}}, "4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": prompt}}, "5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}}, "6": {"class_type": "EmptySD3LatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}}, "7": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3.0}}, "8": {"class_type": "KSampler", "inputs": {"model": ["7", 0], "seed": seed, "steps": 8, "cfg": 1.0, "sampler_name": "res_multistep", "scheduler": "simple", "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "denoise": 1.0}}, "9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}}, "10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}}, } # ---------- LTX I2V 出视频 ---------- def ltx_i2v_workflow(image_name, prompt, seed, prefix, frames=49, width=768, height=512): return { "1": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "ltx-2.3-22b-distilled-1.1-Q4_K_M.gguf"}}, "2": {"class_type": "LTXAVTextEncoderLoader", "inputs": {"text_encoder": "gemma_3_12B_it.safetensors", "ckpt_name": "ltx-2.3-22b-embconn.safetensors", "device": "cpu"}}, "3": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": prompt}}, "4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": LTX_NEG}}, "5": {"class_type": "LTXVConditioning", "inputs": {"positive": ["3", 0], "negative": ["4", 0], "frame_rate": 24.0}}, "6": {"class_type": "LoadImage", "inputs": {"image": image_name}}, "7": {"class_type": "VAELoader", "inputs": {"vae_name": "LTX23_video_vae_bf16.safetensors"}}, "8": {"class_type": "LTXVImgToVideo", "inputs": {"positive": ["5", 0], "negative": ["5", 1], "vae": ["7", 0], "image": ["6", 0], "width": width, "height": height, "length": frames, "batch_size": 1, "strength": 1.0}}, "9": {"class_type": "RandomNoise", "inputs": {"noise_seed": seed}}, "10": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler_ancestral_cfg_pp"}}, "11": {"class_type": "CFGGuider", "inputs": {"model": ["1", 0], "positive": ["8", 0], "negative": ["8", 1], "cfg": 1.0}}, "12": {"class_type": "ManualSigmas", "inputs": {"sigmas": "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0"}}, "13": {"class_type": "SamplerCustomAdvanced", "inputs": {"noise": ["9", 0], "guider": ["11", 0], "sampler": ["10", 0], "sigmas": ["12", 0], "latent_image": ["8", 2]}}, "14": {"class_type": "LTXVTiledVAEDecode", "inputs": {"vae": ["7", 0], "latents": ["13", 0], "horizontal_tiles": 2, "vertical_tiles": 2, "overlap": 6, "last_frame_fix": False, "working_device": "cpu", "working_dtype": "float16"}}, "15": {"class_type": "CreateVideo", "inputs": {"images": ["14", 0], "fps": 24.0}}, "16": {"class_type": "SaveVideo", "inputs": {"video": ["15", 0], "filename_prefix": prefix, "format": "mp4", "codec": "h264"}}, } def shot_to_prompt(shot, style=""): parts = [] cam = shot.get('camera', '中景') scenes = shot.get('scenes', []) chars = shot.get('characters', []) desc = shot.get('description', '') if cam: parts.append(cam) if scenes: parts.append("in " + scenes[0]) if chars: parts.append("with " + ", ".join(chars[:3])) if desc: parts.append(desc[:120]) if style: parts.append(style) return ", ".join(parts) def shot_to_ltx_prompt(desc, camera=""): """分镜 → LTX I2V 运动提示词(四段式简化:起势→动作→环境→收尾)""" base = (desc or "").strip() action = base if base else "the subject moves naturally" parts = [ f"Opening on the first frame scene, {camera or 'medium shot'}. The subject {action}, " "with gentle flowing motion, grass and light drifting softly around. " "Camera holds steady, shallow depth of field, warm natural light. " "Slow, fluid, seamless motion, high detail. " "Ends with the subject settling into a calm final pose." ] return " ".join(parts) # ---------- 各阶段 ---------- def stage_storyboard(script, episode, pro=False): args = [script, "-e", str(episode)] if pro: args.append("--pro") run_script("tools/run_storyboard.py", args) d = os.path.dirname(os.path.abspath(script)) m = glob.glob(os.path.join(d, "STORYBOARD*.json")) if not m: raise RuntimeError("未找到分镜 JSON") return m[0] def stage_route(sb_json): run_script("tools/route_shots.py", [sb_json]) return sb_json def stage_render(sb_json, out_dir=None, style="", max_shots=0): sb = json.load(open(sb_json, encoding="utf-8")) shots = sb.get("shots", []) if max_shots: shots = shots[:max_shots] if not shots: raise RuntimeError("分镜无 shots") if not out_dir: out_dir = os.path.join(os.path.dirname(os.path.abspath(sb_json)), "renders") os.makedirs(out_dir, exist_ok=True) manifest = os.path.join(out_dir, "shot_manifest.json") entries = [] for i, sh in enumerate(shots): p = shot_to_prompt(sh, style) seed = 1000 + i * 17 prefix = f"eed_shot_{i+1:04d}" pid = comfy_submit(zimage_workflow(p, seed, prefix)) print(f" 🎬 [{i+1}/{len(shots)}] {sh.get('shot_number', f'S{i+1:02d}')}: {p[:50]}...") comfy_wait(pid) # 等文件落盘 img = os.path.join(COMFY_OUT, f"{prefix}_00001_.png") wait_file(img) entries.append({"shot": sh.get("shot_number", f"S{i+1:02d}"), "prompt": p, "ltx_desc": sh.get("description",""), "camera": sh.get("camera",""), "seed": seed, "prefix": prefix}) with open(manifest, "w", encoding="utf-8") as f: json.dump({"out_dir": out_dir, "shots": entries}, f, ensure_ascii=False, indent=2) print(f" 📦 出图清单: {manifest}") return manifest def stage_video(manifest, frames=49, max_shots=0): data = json.load(open(manifest, encoding="utf-8")) entries = data["shots"] if max_shots: entries = entries[:max_shots] vdir = os.path.join(data["out_dir"], "videos") os.makedirs(vdir, exist_ok=True) vlist = [] for i, e in enumerate(entries): src = os.path.join(COMFY_OUT, f"{e['prefix']}_00001_.png") if not os.path.isfile(src): print(f" ⚠️ 缺起始图 {src},跳过 {e['shot']}"); continue iname = f"agent_shot_{i+1:04d}.png" shutil.copy(src, os.path.join(COMFY_IN, iname)) prompt = shot_to_ltx_prompt(e.get("ltx_desc", ""), e.get("camera", "")) seed = 5000 + i * 29 prefix = f"eed_vid_{i+1:04d}" pid = comfy_submit(ltx_i2v_workflow(iname, prompt, seed, prefix, frames=frames)) print(f" 🎥 [{i+1}/{len(entries)}] {e['shot']} LTX I2V {frames}帧 提交,预计 {frames*4}s...") comfy_wait(pid, timeout=900) vout = os.path.join(COMFY_OUT, f"{prefix}_00001_.mp4") if not wait_file(vout, timeout=60): print(f" ⚠️ 视频未落盘 {vout}") continue dest = os.path.join(vdir, f"shot_{i+1:04d}.mp4") shutil.copy(vout, dest) vlist.append({"shot": e["shot"], "file": dest, "prompt": prompt}) print(f" ✅ {e['shot']} → {dest}") vman = os.path.join(vdir, "video_manifest.json") with open(vman, "w", encoding="utf-8") as f: json.dump({"video_dir": vdir, "videos": vlist}, f, ensure_ascii=False, indent=2) print(f" 📦 视频清单: {vman}") return vman def stage_compose(vman_or_dir, output="", title="", credits=""): if os.path.isfile(vman_or_dir) and vman_or_dir.endswith("video_manifest.json"): d = json.load(open(vman_or_dir, encoding="utf-8")) folder = d["video_dir"] else: folder = vman_or_dir vids = sorted(glob.glob(os.path.join(folder, "shot_*.mp4"))) if not vids: raise RuntimeError(f"没有可拼接的视频片段: {folder}") if not output: output = os.path.join(os.path.dirname(folder), "EP01.mp4") if len(vids) == 1 and not title and not credits: # 单段且无片头片尾:直接拷贝(LTX 视频无声,video_composer 方案B 会因缺音频流失败) shutil.copy(vids[0], output) print(f" 🎬 单段成片(直接拷贝): {output}") return output args = [folder, "-o", output] if title: args += ["--title", title] if credits: args += ["--credits", credits] run_script("tools/video_composer.py", args) print(f" 🎬 成片: {output}") return output def _video_duration(path): try: r = subprocess.run(["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", path], capture_output=True, text=True) return float(r.stdout.strip() or 0) except Exception: return 0 def stage_audio_pipeline(sb_json, composed, ep_dir): """⑥ 声画装配:配音 → 字幕 → BGM → 混音 → 有声终成片""" from tools import audio_pipeline as ap print(" 🎙 配音 (Edge-TTS)...") voice = ap.generate_voice(sb_json, ep_dir) print(" 📝 字幕...") ap.generate_srt(sb_json, ep_dir, voice) dur = _video_duration(composed) or 20 print(f" 🎵 BGM ({dur:.0f}s)...") ap.generate_bgm(ep_dir, int(dur) + 2) final = os.path.join(ep_dir, "FINAL.mp4") print(" 🎛 混音...") ap.mix_final(composed, os.path.join(ep_dir, "voice"), os.path.join(ep_dir, "bgm.mp3"), final) print(f" 🎉 有声终成片: {final}") return final def main(): ap = argparse.ArgumentParser(description="苍耳 · 一键短剧 Agent") ap.add_argument("input", help="剧本 / 分镜JSON(--from render,route) / 清单(--from video,compose)") ap.add_argument("-e", "--episode", type=int, default=1) ap.add_argument("--pro", action="store_true", help="分镜用Pro模型") ap.add_argument("--style", default="", help="全局风格") ap.add_argument("--until", choices=["storyboard","route","render","video","compose","audio"], default="render") ap.add_argument("--from", dest="from_stage", choices=["route","render","video","compose","audio"]) ap.add_argument("--max-shots", type=int, default=0, help="只跑前 N 镜") ap.add_argument("--frames", type=int, default=49, help="LTX 视频帧数(默认49)") ap.add_argument("-o", "--output", default="", help="成片路径") args = ap.parse_args() sb_json = None if args.from_stage in ("video", "compose"): manifest = args.input print(f"📖 从清单续跑: {manifest}") if args.from_stage == "video": vman = stage_video(manifest, frames=args.frames, max_shots=args.max_shots) if args.from_stage == "compose": out = stage_compose(manifest, args.output) print("🎯 成片完成:", out); return if args.from_stage == "audio": out = stage_compose(manifest, args.output) sb = glob.glob(os.path.join(os.path.dirname(os.path.dirname(out)), "STORYBOARD*.json")) if sb: ep_dir = os.path.dirname(os.path.abspath(out)) final = stage_audio_pipeline(sb[0], out, ep_dir) print("🎯 有声终成片:", final) return print("🎯 阶段完成。续跑: --from compose") return if args.from_stage: sb_json = args.input if not os.path.isfile(sb_json): print(f"❌ 找不到分镜: {sb_json}"); sys.exit(1) print(f"📖 从分镜续跑: {sb_json}") else: if not os.path.isfile(args.input): print(f"❌ 找不到剧本: {args.input}"); sys.exit(1) print(f"🚀 苍耳 · 一键短剧 Agent | 剧本: {args.input} | EP{args.episode:02d} | 到: {args.until}") print("═" * 44); print("① 剧本→分镜 (豆包, ¥0.01/集 级)"); print("═" * 44) sb_json = stage_storyboard(args.input, args.episode, args.pro) print(f"✅ 分镜: {sb_json}") if not sb_json: sys.exit(1) if args.from_stage in (None, "storyboard") and args.until not in ("storyboard",): print("═" * 44); print("② 逐镜路由分配 (豆包)"); print("═" * 44) sb_json = stage_route(sb_json) print("✅ 路由完成") if args.until in ("render", "video", "compose"): print("═" * 44); print("③ 逐镜出图 (本地 Z-Image, 零成本)"); print("═" * 44) manifest = stage_render(sb_json, style=args.style, max_shots=args.max_shots) else: print("🎯 阶段完成。"); return if args.until in ("video", "compose"): print("═" * 44); print("④ 逐镜视频 (本地 LTX I2V, 零成本)"); print("═" * 44) vman = stage_video(manifest, frames=args.frames, max_shots=args.max_shots) else: print("🎯 出图完成。续跑: --from video <清单> 或 --until video") return if args.until in ("compose", "audio"): print("═" * 44); print("⑤ 剪辑拼接 (FFmpeg)"); print("═" * 44) out = stage_compose(vman, args.output) print(f"\n🎬 成片: {out}") if args.until == "audio": print("═" * 44); print("⑥ 声画装配 (配音+字幕+BGM+混音)"); print("═" * 44) sb = sb_json or (glob.glob(os.path.join(os.path.dirname(os.path.dirname(out)), "STORYBOARD*.json")) or [None])[0] if sb: ep_dir = os.path.dirname(os.path.abspath(out)) final = stage_audio_pipeline(sb, out, ep_dir) print(f"\n🎉🎉 有声终成片: {final}") else: print("⚠️ 未找到分镜JSON,跳过声画装配") elif args.until != "compose": print("🎯 视频完成。续跑: --from compose <清单> 或 --until compose/audio") if __name__ == "__main__": main()