#!/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 <manifest> --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()
