#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
《说书人》MV 动态分镜预览（animatic）生成器

- 输入：04-动态预览/shots.json（镜头表：素材 / 时长 / 运镜 / 歌词）
- 输出：04-动态预览/说书人-animatic-无声预览.mp4
- 单镜头用 ffmpeg zoompan 做 Ken Burns 运镜，叠加歌词字幕与镜号角标，
  最后用 concat demuxer 无损拼接。

⚠️ 时长目前按歌词结构估算，拿到正式音频后用真实时间码替换 shots.json 的
   dur 字段并重跑本脚本即可，其余逻辑无需改动。

用法：python3 build_animatic.py [--preview]   # --preview 只渲染前 3 镜自检
"""
import json
import os
import subprocess
import sys
import tempfile

BASE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(BASE)
FONT = "/usr/share/fonts/opentype/source-han-cjk/SourceHanSansSC-Medium.otf"
FPS = 30
W, H = 1920, 1080
SRC_W, SRC_H = 3840, 2160      # 先放大再缩放，避免 zoompan 变糊
ZMAX = 0.18                     # 最大额外缩放比例

# ---------------------------------------------------------------- 镜头表
# (素材, 时长秒, 运镜, 歌词/文案, 是否角标)
SHOTS = [
    # —— 片头 ——
    ("03-场景设定/场景-江山风雨-水墨.png", 9, "out", "说书人", "title"),
    # —— 段落1 引子（寅子）——
    ("03-场景设定/场景-路边茶楼-水墨.png", 5, "in",  "路边的茶楼 人影错落", ""),
    ("03-场景设定/场景-街市吆喝-水墨.png", 4, "right", "街上传来 两三声吆喝", ""),
    ("02-人物设定/说书人-设定图-写实.png", 4, "in",  "人前摇扇 醒木拍桌", ""),
    ("02-人物设定/茶楼看客群像-写实.png", 4, "left",  "各位看官 你细听分说", ""),
    # —— 段落2 主题（暗杠）——
    ("03-场景设定/场景-江山风雨-水墨.png", 6, "out", "这江山风雨 岁月山河", ""),
    ("02-人物设定/侠女-设定图-写实.png",   4, "in",  "刀光剑影 美了多少世间传说", ""),
    ("02-人物设定/说书人-设定图-写实.png", 5, "right", "且看他口若悬河 衣上有风尘", ""),
    ("02-人物设定/说书人-设定图-写实.png", 4, "in",  "却原来是一位江湖说书人", ""),
    # —— 段落3 天山 / 昆仑 ——
    ("03-场景设定/场景-天山枯城-水墨.png", 6, "in",  "那天山女子 独守枯城", ""),
    ("02-人物设定/天山女子-设定图-写实.png", 5, "in", "也只是为了曾经的那一个人", ""),
    ("03-场景设定/场景-昆仑雪山-水墨.png", 5, "out", "那昆仑痴儿 一情难分", ""),
    ("02-人物设定/昆仑痴儿-设定图-写实.png", 5, "out", "谁曾想这一去再不相逢", ""),
    # —— 段落4 众生相（寅子）——
    ("03-场景设定/场景-江山风雨-水墨.png", 5, "right", "这江山风雨 岁月山河", ""),
    ("02-人物设定/茶楼看客群像-写实.png",  4, "in",  "侠骨柔肠 醉了多少此间看客", ""),
    ("02-人物设定/游子异客-设定图-写实.png", 5, "left", "本就是浮萍游子漂泊本无根", ""),
    ("03-场景设定/场景-酒剑随马-水墨.png", 5, "in",  "萍水相逢浪迹天涯君莫问", ""),
    ("03-场景设定/场景-江山风雨-水墨.png", 5, "out", "那江山如画 各走一程", ""),
    ("03-场景设定/场景-街市吆喝-水墨.png", 5, "right", "也苦尽了人间的多少苍生", ""),
    ("02-人物设定/天山女子-设定图-写实.png", 4, "in", "那美女多娇 爱看英雄", ""),
    ("02-人物设定/侠女-设定图-写实.png",   4, "in",  "道尽了江湖的血雨腥风", ""),
    # —— 段落5 副歌（合）——
    ("03-场景设定/场景-城中楼阁-水墨.png", 5, "in",  "城中楼阁 几经风霜", ""),
    ("02-人物设定/游子异客-设定图-写实.png", 5, "in", "天涯游子 一梦黄粱", ""),
    ("03-场景设定/场景-神鬼志异-水墨.png", 5, "in",  "神鬼志异 荒唐一场", ""),
    ("02-人物设定/说书人-设定图-写实.png", 4, "right", "谈笑一段 半生疏狂", ""),
    ("03-场景设定/场景-城中楼阁-水墨.png", 5, "out", "江山易老嘛 几度斑驳", ""),
    ("02-人物设定/昆仑痴儿-设定图-写实.png", 4, "right", "痴儿侠女 奈何情多", ""),
    ("03-场景设定/场景-酒剑随马-水墨.png", 5, "left", "酒剑随马 他乡异客", ""),
    ("03-场景设定/场景-桃花林-水墨.png",   6, "in",  "白衣不见 桃花如昨", ""),
    # —— 段落6 插科打诨 ——
    ("03-场景设定/场景-戏台-水墨.png",     5, "in",  "哎呦 嘛时候是津门第一捏", "dialogue"),
    ("02-人物设定/判官-设定图-写实.png",   4, "in",  "弟弟", "dialogue"),
    ("02-人物设定/茶楼看客群像-写实.png",  4, "right", "干嘛啊", "dialogue"),
    ("02-人物设定/判官-设定图-写实.png",   5, "in",  "这猴不卖", "dialogue"),
    # —— 段落7 收束（寅子）——
    ("02-人物设定/茶楼看客群像-写实.png",  4, "left", "在坐的看官 莫想太多", ""),
    ("02-人物设定/说书人-设定图-写实.png", 5, "in",  "书中故事 是世间蹉跎", ""),
    ("03-场景设定/场景-路边茶楼-水墨.png", 5, "out", "各人心中 它自有评说", ""),
    ("02-人物设定/说书人-设定图-写实.png", 4, "right", "听完这段 你一笑而过", ""),
    # —— 段落8 副歌再现 + 尾声（暗杠）——
    ("03-场景设定/场景-城中楼阁-水墨.png", 4, "in",  "城中楼阁 几经风霜", ""),
    ("02-人物设定/游子异客-设定图-写实.png", 4, "in", "天涯游子 一梦黄粱", ""),
    ("03-场景设定/场景-神鬼志异-水墨.png", 4, "in",  "神鬼志异 荒唐一场", ""),
    ("02-人物设定/说书人-设定图-写实.png", 4, "in",  "谈笑一段 半生疏狂", ""),
    ("03-场景设定/场景-江山风雨-水墨.png", 5, "out", "这江山风雨 岁月山河", ""),
    ("02-人物设定/说书人-设定图-写实.png", 5, "in",  "人世苦短 又有几人看破", ""),
    ("03-场景设定/场景-戏台-水墨.png",     6, "in",  "大梦一场也只是戏中你我", ""),
    ("02-人物设定/说书人-设定图-写实.png", 6, "in",  "欲知后事如何 且听我下回分说", ""),
    # —— 片尾 ——
    ("03-场景设定/场景-桃花林-水墨.png",  10, "out", "说书人 · 暗杠 / 寅子", "title"),
]


def zoompan_expr(motion: str, n: int) -> str:
    """返回 zoompan 的 z/x/y 表达式片段。"""
    zmax = ZMAX
    zr = zmax / max(n - 1, 1)
    cx = "iw/2-(iw/zoom/2)"
    cy = "ih/2-(ih/zoom/2)"
    if motion == "in":
        z = f"min(1+{zr:.6f}*on,{1+zmax})"
        x, y = cx, cy
    elif motion == "out":
        z = f"max({1+zmax}-{zr:.6f}*on,1.0)"
        x, y = cx, cy
    elif motion == "right":
        z = f"{1+zmax/2:.4f}"
        x, y = f"(iw-iw/zoom)*on/{n-1}", cy
    elif motion == "left":
        z = f"{1+zmax/2:.4f}"
        x, y = f"(iw-iw/zoom)*(1-on/{n-1})", cy
    else:
        raise ValueError(f"未知运镜: {motion}")
    return f"zoompan=z='{z}':x='{x}':y='{y}':d={n}:s={W}x{H}:fps={FPS}"


def esc(path: str) -> str:
    """ffmpeg filter 参数里的路径转义。"""
    return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'")


def build_segment(idx, shot, tmpdir):
    img, dur, motion, lyric, kind = shot
    src = os.path.join(ROOT, img)
    if not os.path.exists(src):
        raise FileNotFoundError(src)
    n = int(round(dur * FPS))
    out = os.path.join(tmpdir, f"seg_{idx:04d}.mp4")

    filters = [
        f"scale={SRC_W}:{SRC_H}:force_original_aspect_ratio=increase",
        f"crop={SRC_W}:{SRC_H}",
        zoompan_expr(motion, n),
    ]

    # 角标：镜号 + 素材名（正式成片会去掉）
    label = f"S{idx:02d} · {os.path.basename(img).replace('.png','')}"
    lab_file = os.path.join(tmpdir, f"lab_{idx:04d}.txt")
    with open(lab_file, "w", encoding="utf-8") as f:
        f.write(label)
    filters.append(
        f"drawtext=fontfile={FONT}:textfile='{esc(lab_file)}'"
        f":x=28:y=24:fontsize=26:fontcolor=white@0.55:borderw=2:bordercolor=black@0.5"
    )

    # 歌词 / 标题
    if lyric:
        ly_file = os.path.join(tmpdir, f"ly_{idx:04d}.txt")
        with open(ly_file, "w", encoding="utf-8") as f:
            f.write(lyric)
        if kind == "title":
            filters.append(
                f"drawtext=fontfile={FONT}:textfile='{esc(ly_file)}'"
                f":x=(w-text_w)/2:y=(h-text_h)/2:fontsize=96:fontcolor=white"
                f":borderw=4:bordercolor=black@0.7"
            )
        else:
            color = "0xFFE9A8" if kind == "dialogue" else "white"
            filters.append(
                f"drawtext=fontfile={FONT}:textfile='{esc(ly_file)}'"
                f":x=(w-text_w)/2:y=h-140:fontsize=46:fontcolor={color}"
                f":borderw=3:bordercolor=black@0.85"
            )

    filters.append("format=yuv420p")

    # 片尾淡出到黑
    if idx == len(SHOTS):
        filters.append(f"fade=t=out:st={dur-1.5:.2f}:d=1.5")

    cmd = ["ffmpeg", "-y", "-loglevel", "error", "-i", src,
           "-vf", ",".join(filters), "-frames:v", str(n),
           "-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
           "-pix_fmt", "yuv420p", out]
    subprocess.run(cmd, check=True)
    return out


def main():
    preview = "--preview" in sys.argv
    shots = SHOTS[:3] if preview else SHOTS
    total = sum(s[1] for s in shots)
    print(f"共 {len(shots)} 个镜头，估算总时长 {total}s ≈ {total//60}分{total%60}秒")

    # 落一份 shots.json，便于音频到位后改时间码
    cfg = os.path.join(BASE, "shots.json")
    with open(cfg, "w", encoding="utf-8") as f:
        json.dump([{"index": i + 1, "image": s[0], "dur": s[1], "motion": s[2],
                    "text": s[3], "kind": s[4]} for i, s in enumerate(SHOTS)],
                  f, ensure_ascii=False, indent=1)

    tmpdir = tempfile.mkdtemp(prefix="animatic_")
    segs = []
    for i, shot in enumerate(shots, 1):
        print(f"  [{i}/{len(shots)}] {shot[1]}s {shot[2]:5s} {shot[3][:22]}")
        segs.append(build_segment(i, shot, tmpdir))

    listfile = os.path.join(tmpdir, "list.txt")
    with open(listfile, "w", encoding="utf-8") as f:
        for s in segs:
            f.write(f"file '{s}'\n")

    out = os.path.join(BASE, "说书人-animatic-无声预览.mp4" if not preview else "预览-前3镜.mp4")
    subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat",
                    "-safe", "0", "-i", listfile, "-c", "copy", out], check=True)
    print(f"\n✅ 输出：{out}")
    subprocess.run(["ffprobe", "-v", "error", "-show_entries",
                    "format=duration,size", "-of", "default=nw=1", out])


if __name__ == "__main__":
    main()
