#!/usr/bin/env python3
"""
audio_pipeline.py — 声画流水线：配音 / 字幕 / BGM / 混音
剧本有声成片的最后一段：把分镜 dialogue 变成配音+字幕+BGM，混入成片。

用法:
  python audio_pipeline.py voice <分镜.json> <输出目录>      # Edge-TTS 配音
  python audio_pipeline.py srt <分镜.json> <输出目录>        # 生成 SRT 字幕
  python audio_pipeline.py bgm <输出目录> <秒数>             # 生成 BGM（stable_audio 失败则 ffmpeg 氛围音）
  python audio_pipeline.py mix <成片.mp4> <配音目录> <bgm.mp3> <输出.mp4>   # 混音
"""
import os, sys, json, asyncio, subprocess, math

VOICES = {
    "男": "zh-CN-YunxiNeural",
    "女": "zh-CN-XiaoxiaoNeural",
    "旁白": "zh-CN-XiaoyiNeural",
    "少年": "zh-CN-YunyangNeural",
    "老人": "zh-CN-YunfengNeural",
}


def load_shots(sb_json):
    d = json.load(open(sb_json, encoding="utf-8"))
    shots = d.get("shots", [])
    return shots


def _dialogue_of(shot):
    """从分镜镜头提取台词（支持字符串或 dict）。"""
    dl = shot.get("dialogue", "")
    if not dl:
        return []
    if isinstance(dl, str):
        return [{"text": dl, "char": shot.get("characters", [""])[0] if shot.get("characters") else "旁白"}]
    if isinstance(dl, list):
        out = []
        for it in dl:
            if isinstance(it, str):
                out.append({"text": it, "char": shot.get("characters", [""])[0] if shot.get("characters") else "旁白"})
            elif isinstance(it, dict):
                out.append({"text": it.get("text", ""), "char": it.get("character", it.get("char", "旁白"))})
        return [x for x in out if x["text"].strip()]
    return []


def generate_voice(sb_json, out_dir):
    """Edge-TTS 逐条配音。返回 [(shot_no, 文件路径, 台词, 时长)]"""
    shots = load_shots(sb_json)
    vdir = os.path.join(out_dir, "voice")
    os.makedirs(vdir, exist_ok=True)
    results = []
    for i, sh in enumerate(shots):
        lines = _dialogue_of(sh)
        if not lines:
            continue
        shot_no = sh.get("shot_number", f"S{i+1:02d}")
        for j, ln in enumerate(lines):
            voice = VOICES.get(ln["char"], VOICES.get(ln["char"][:1], "zh-CN-XiaoxiaoNeural"))
            fname = f"shot_{i+1:04d}_{j:02d}.mp3"
            fpath = os.path.join(vdir, fname)
            if not os.path.isfile(fpath):
                text = ln["text"][:500]
                try:
                    asyncio.run(_tts(text, voice, fpath))
                except Exception as e:
                    print(f"  ⚠️ 配音失败 {shot_no}:{ln['char']}: {e}")
                    continue
            dur = _mp3_duration(fpath)
            results.append({"shot": shot_no, "char": ln["char"], "text": ln["text"],
                            "file": fpath, "duration": dur})
            print(f"  🎙 [{shot_no}] {ln['char']}: {ln['text'][:40]} ({dur:.1f}s)")
    return results


async def _tts(text, voice, fpath):
    import edge_tts
    c = edge_tts.Communicate(text, voice)
    await c.save(fpath)


def _mp3_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 generate_srt(sb_json, out_dir, voice_results=None):
    """生成 SRT 字幕（基于配音时长分配时间轴）。"""
    shots = load_shots(sb_json)
    sdir = os.path.join(out_dir, "subs")
    os.makedirs(sdir, exist_ok=True)
    srt_path = os.path.join(sdir, "subtitles.srt")
    idx = 1
    t = 0.0
    lines = []
    for i, sh in enumerate(shots):
        lines_d = _dialogue_of(sh)
        if not lines_d:
            continue
        shot_no = sh.get("shot_number", f"S{i+1:02d}")
        for ln in lines_d:
            dur = 2.5
            if voice_results:
                for vr in voice_results:
                    if vr["shot"] == shot_no and vr["char"] == ln["char"]:
                        dur = max(1.0, vr["duration"] + 0.3)
                        break
            st = t
            t += dur
            lines.append(f"{idx}\n{_fmt_srt(st)} --> {_fmt_srt(t)}\n{ln['char']}: {ln['text']}\n")
            idx += 1
    with open(srt_path, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))
    print(f"  📝 字幕: {srt_path} ({idx-1} 条)")
    return srt_path


def _fmt_srt(sec):
    h = int(sec // 3600); m = int((sec % 3600) // 60); s = int(sec % 60); ms = int((sec - int(sec)) * 1000)
    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"


def generate_bgm(out_dir, seconds=20, mood="calm ambient background music, gentle piano", use_stable=False):
    """生成 BGM：默认 ffmpeg 氛围音（稳定）；use_stable=True 时先试 stable_audio_3。"""
    bgm_path = os.path.join(out_dir, "bgm.mp3")
    ok = False
    if use_stable:
        ok = _try_stable_audio(bgm_path, seconds, mood)
        if ok:
            print(f"  🎵 BGM(stable_audio_3): {bgm_path} ({seconds}s)")
    if not ok:
        _ffmpeg_pad(bgm_path, seconds)
        print(f"  🎵 BGM(ffmpeg氛围音): {bgm_path} ({seconds}s)")
    return bgm_path


def _try_stable_audio(path, seconds, mood):
    """调本地 ComfyUI stable_audio_3 生成。返回 bool。"""
    try:
        import json, urllib.request, time
        COMFY = "http://127.0.0.1:8188"
        sigmas = ", ".join(str(round(1.0 - i / 24, 4)) for i in range(25)) + ", 0.0"
        wf = {
            "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "stable_audio_3_medium_base.safetensors"}},
            "2": {"class_type": "CLIPTextEncode", "inputs": {"text": mood, "clip": ["1", 1]}},
            "3": {"class_type": "CLIPTextEncode", "inputs": {"text": "vocals, singing, noisy, harsh, distorted, speech", "clip": ["1", 1]}},
            "4": {"class_type": "ConditioningStableAudio", "inputs": {"positive": ["2", 0], "negative": ["3", 0], "seconds_start": 0, "seconds_total": seconds}},
            "5": {"class_type": "EmptyLatentAudio", "inputs": {"seconds": seconds, "batch_size": 1}},
            "6": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler"}},
            "9": {"class_type": "ManualSigmas", "inputs": {"sigmas": sigmas}},
            "10": {"class_type": "SamplerCustom", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["3", 0], "cfg": 4.0, "noise_seed": 42, "add_noise": True, "sampler": ["6", 0], "sigmas": ["9", 0], "latent_image": ["5", 0]}},
            "11": {"class_type": "VAEDecodeAudio", "inputs": {"samples": ["10", 0], "vae": ["1", 2]}},
            "12": {"class_type": "SaveAudio", "inputs": {"audio": ["11", 0], "filename_prefix": "eed_bgm_gen"}},
        }
        data = json.dumps({"prompt": wf}).encode()
        req = urllib.request.Request(f"{COMFY}/prompt", data=data, headers={"Content-Type": "application/json"})
        pid = json.loads(urllib.request.urlopen(req, timeout=15).read())["prompt_id"]
        for _ in range(15):  # 30秒内不成功即放弃，避免卡住
            time.sleep(2)
            h = json.loads(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=8).read())
            if pid in h and h[pid].get("outputs"):
                aud = h[pid]["outputs"].get("12", {}).get("audio", [])
                if aud:
                    import shutil
                    shutil.copy(os.path.join(os.path.expanduser("~/comfy/ComfyUI/output"), aud[0]["filename"]), path)
                    return True
            if pid in h and h[pid].get("status", {}).get("status_str") == "error":
                return False
    except Exception:
        return False
    return False


def _ffmpeg_pad(path, seconds):
    """ffmpeg 生成柔和氛围音垫底（aevalsrc 单输入多频和声，避免多 lavfi 输入偶发失败）。"""
    expr = (f"0.04*sin(2*PI*220*t)+0.04*sin(2*PI*277*t)+0.04*sin(2*PI*330*t)"
            f"+0.03*sin(2*PI*165*t)")
    cmd = ["ffmpeg", "-y", "-f", "lavfi",
           "-i", f"aevalsrc={expr}:d={seconds}:s=44100",
           "-af", "lowpass=f=1500,volume=0.35", path]
    try:
        r = subprocess.run(cmd, capture_output=True, timeout=60)
        if r.returncode != 0:
            print(f"  ⚠️ BGM ffmpeg 退出码 {r.returncode}: {(r.stderr or '')[-200:]}")
    except Exception as e:
        print(f"  ⚠️ BGM ffmpeg 异常: {e}")


def mix_final(video, voice_dir, bgm, out_path, subtitles=None):
    """ffmpeg 混音：成片(无声) + 配音 + BGM(压低)。"""
    v = ["-i", video]
    inputs = [video]
    filters = []
    n = 0
    vf_in = {}
    if voice_dir and os.path.isdir(voice_dir):
        mps = sorted([os.path.join(voice_dir, f) for f in os.listdir(voice_dir) if f.endswith(".mp3")])
        for mp in mps:
            inputs.append(mp); vf_in[f"v{n+1}"] = mp
            n += 1
    if bgm and os.path.isfile(bgm):
        inputs.append(bgm); vf_in[f"v{n+1}"] = bgm
        n += 1
    if not inputs[1:]:
        # 无任何音轨：直接复制视频
        subprocess.run(["ffmpeg", "-y", "-i", video, "-c", "copy", out_path], capture_output=True)
        return out_path
    # 简单混音：所有输入 amix，BGM 音量压低
    labels = [f"[{i}:a]" for i in range(1, len(inputs))]
    vol = []
    for i in range(1, len(inputs)):
        if inputs[i] == bgm:
            vol.append(f"[{i}:a]volume=0.15[v{i}]")
        else:
            vol.append(f"[{i}:a][v{i}]" if False else f"[{i}:a]volume=1.0[v{i}]")
    fc = ";".join(vol) + ";" + "".join(f"[v{i}]" for i in range(1, len(inputs))) + f"amix=inputs={n}:normalize=0[aout]"
    cmd = ["ffmpeg", "-y"] + sum([["-i", i] for i in inputs], []) + \
          ["-filter_complex", fc, "-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", out_path]
    try:
        subprocess.run(cmd, capture_output=True, timeout=120)
    except Exception as e:
        print("  混音失败:", e)
    return out_path


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(__doc__); sys.exit(0)
    cmd = sys.argv[1]
    if cmd == "voice" and len(sys.argv) >= 4:
        generate_voice(sys.argv[2], sys.argv[3])
    elif cmd == "srt" and len(sys.argv) >= 4:
        generate_srt(sys.argv[2], sys.argv[3])
    elif cmd == "bgm" and len(sys.argv) >= 4:
        generate_bgm(sys.argv[2], int(float(sys.argv[3])), sys.argv[4] if len(sys.argv) > 4 else "calm ambient background music, gentle piano")
    elif cmd == "mix" and len(sys.argv) >= 5:
        mix_final(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5])
    else:
        print(__doc__)
