# -*- coding: utf-8 -*-
"""考察回「壁打ちの型」の書き出しの部品（2026-11-02 1本目「頭脳は脳のように分かれる」で作った）。
紙（考察ノート）の上の文字の動きは HTML の CSS アニメーションで描き、時刻を止めて1コマずつ撮る（決まった絵になる）。
ハコブくん（口・目・リング・飛び出す・コケる）、エイイチのドット絵（声に合わせて小さく弾む）、字幕は、ここで重ねる。

使い方（動画の作業フォルダで。scenes_kabe.py に場面を書く）
  python3 kabe_kit.py sheet          場面ごとの静止画の一覧（一覧_場面.jpg）
  python3 kabe_kit.py render [名前..] 紙の映像を作る（できているものは使い回す）
  python3 kabe_kit.py build [--first-blocks N]  重ねて書き出す → out.mp4・out_確認用.mp4・out.srt
"""
import json, os, sys, subprocess, hashlib, html as _html, wave
import numpy as np
from PIL import Image, ImageFilter

HERE = os.path.dirname(os.path.abspath(__file__))
for _c in (os.environ.get("P2M_BASE", ""), os.path.join(HERE, "..", "_制作ベース")):
    if _c and os.path.exists(os.path.join(_c, "hakobu.py")):
        BASE = os.path.abspath(_c); break
sys.path.insert(0, HERE)
sys.path.insert(1, BASE)
os.environ.setdefault("P2M_BASE", BASE)
from timing import FPS, SR, load, layout, narration, write_wav
from subtitle import pages as sub_pages, split_lines, SUB_CX, SUB_MAXW, SUB_MAXC
import hakobu as hk

W, H = 1920, 1080

# ---------------- ハコブくんの顔を足す（ショートの動き hakobu_motion.py の顔の形だけを読む） ----------------
_src = open(os.path.join(BASE, "hakobu_motion.py"), encoding="utf-8").read().split("# ================= 体と背景の準備")[0]
_ns = {"__name__": "hakobu_faces"}
exec(compile(_src, "hakobu_motion(顔)", "exec"), _ns)
hk.EXPR.setdefault("x", dict(eyes=("x", "x"), brows=None, rest="o", closed="o"))
hk.EXPR.setdefault("dot", dict(eyes=("dot", "dot"), brows=None, rest="flat", closed="flat"))
hk.EXPR.setdefault("down", dict(eyes=("down", "down"), brows="trouble", rest="frown", closed="frown"))

# ---------------- 紙の CSS（動きの試しの base.css を本番用に） ----------------
CSS = r"""
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1920px; height: 1080px; overflow: hidden; background: #22201c; }
.f { width: 1920px; height: 1080px; position: relative; overflow: hidden; background: #22201c; color: #22201c; font-family: "Noto Serif CJK JP", serif; }
.paper { position: absolute; left: 430px; top: 64px; width: 1060px; height: 780px; background: #f5f3ee; box-shadow: 0 30px 60px rgba(0,0,0,.45); }
.tag { position: absolute; left: 0; right: 0; top: 30px; text-align: center; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 500; font-size: 24px; letter-spacing: .2em; color: #5a564f; }
.src { position: absolute; left: 0; right: 0; bottom: 24px; text-align: center; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 500; font-size: 20px; color: #8a857c; }
.v { writing-mode: vertical-rl; }
.center { position: absolute; left: 0; right: 0; top: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.stack { position: absolute; left: 0; right: 0; top: 96px; display: flex; flex-direction: column; align-items: center; gap: 22px; }
.row2 { display: flex; gap: 32px; justify-content: center; }
.card { position: relative; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 500; font-size: 34px; line-height: 1.5; padding: 16px 34px; text-align: center; }
.card small { display: block; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 700; font-size: 20px; letter-spacing: .12em; margin-bottom: 4px; }
.k-q { font-family: "Noto Serif CJK JP", serif; font-size: 46px; font-weight: 700; padding: 0 0 6px; }
.k-fact { background: #22201c; color: #f5f3ee; }
.k-fact small { color: #bdb7ab; }
.k-mi { background: #dcd6c9; color: #22201c; }
.k-mi small { color: #5a564f; }
.k-hb { background: #ece9e2; color: #22201c; }
.k-hb small { color: #5a564f; }
.num { font-family: "Noto Serif CJK JP", serif; font-weight: 900; }
.serif { font-family: "Noto Serif CJK JP", serif; }
@keyframes show { to { opacity: 1; } }
@keyframes hide { to { opacity: 0; } }
[data-ink] > span { display: inline-block; opacity: 0; filter: blur(6px); animation: ink .45s ease-out forwards; }
@keyframes ink { 0% { opacity: 0; filter: blur(6px); transform: translateY(-6px); } 100% { opacity: 1; filter: blur(0); transform: none; } }
.land { display: inline-block; opacity: 0; animation: land .6s cubic-bezier(.2,1.6,.4,1) forwards; }
@keyframes land { 0% { opacity: 0; transform: scale(1.5); filter: blur(4px); } 100% { opacity: 1; transform: scale(1); filter: blur(0); } }
.pop { opacity: 0; animation: pop .45s ease-out forwards; }
@keyframes pop { 0% { opacity: 0; transform: translateY(14px); } 100% { opacity: 1; transform: none; } }
.wipe { clip-path: inset(0 100% 0 0); animation: wipe .45s ease-out forwards; }
@keyframes wipe { to { clip-path: inset(0 0 0 0); } }
.stamp { position: absolute; background: #c4472a; color: #f5f3ee; font-family: "Noto Sans CJK JP", sans-serif; font-size: 30px; font-weight: 900; padding: 10px 20px; letter-spacing: .08em; opacity: 0; animation: slam .35s cubic-bezier(.5,0,.75,0) forwards; white-space: nowrap; }
@keyframes slam { 0% { opacity: 0; transform: rotate(-6deg) scale(2.6); } 85% { opacity: 1; transform: rotate(-6deg) scale(.94); } 100% { opacity: 1; transform: rotate(-6deg) scale(1); } }
.botsu { position: absolute; right: -30px; top: -22px; background: #c4472a; color: #f5f3ee; font-family: "Noto Sans CJK JP", sans-serif; font-size: 26px; font-weight: 900; padding: 4px 14px; opacity: 0; animation: slam .35s cubic-bezier(.5,0,.75,0) forwards; }
@keyframes fadeto { to { opacity: .5; } }
.hl { background: linear-gradient(#e6dfcf,#e6dfcf) no-repeat left 85% / 0% 45%; animation: hl .5s ease-out forwards; }
@keyframes hl { to { background-size: 100% 45%; } }
.dassen { position: absolute; inset: 0; background-size: cover; background-position: center; opacity: 0; }
@keyframes dIn { from { opacity: 0; transform: scale(1.3); } to { opacity: 1; transform: none; } }
@keyframes dOut { to { opacity: 0; } }
@keyframes fly { from { transform: none; } to { transform: translate(500px,-1100px) rotate(28deg); } }
@keyframes back { from { transform: translate(-1400px,0) rotate(-8deg); } to { transform: none; } }
@keyframes paperIn { from { opacity: 0; transform: translateY(40px) rotate(-1.2deg); } to { opacity: 1; transform: none; } }
.big { position: absolute; left: 0; right: 0; text-align: center; transform: rotate(-5deg); font-family: "Noto Sans CJK JP", sans-serif; font-weight: 900; color: #fff; font-size: 120px; line-height: 1.2; -webkit-text-stroke: 4px #141311; text-shadow: 0 10px 0 #c4472a; }
.big span { display: inline-block; opacity: 0; }
@keyframes boom { from { opacity: 0; transform: scale(2.4) rotate(8deg); } to { opacity: 1; transform: none; } }
.trow { display: flex; align-items: center; width: 760px; padding: 16px 30px; background: #e6e1d6; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 500; font-size: 32px; text-align: left; }
.trow b { width: 150px; font-weight: 900; } .trow i { margin-left: auto; font-style: normal; font-size: 24px; color: #5a564f; }
.seg { font-family: "Noto Sans CJK JP", sans-serif; text-align: center; padding: 22px 10px; font-size: 30px; font-weight: 700; }
"""

JS = r"""<script>
function ink(el, start, step){ const t=el.textContent; el.textContent=''; [...t].forEach((c,i)=>{const s=document.createElement('span'); s.textContent=c===' '?' ':c; s.style.animationDelay=(start+i*step)+'s'; el.appendChild(s);}); }
document.querySelectorAll('[data-ink]').forEach(el=>{const [a,b]=el.dataset.ink.split(','); ink(el,+a,+b||0.06);});
document.getAnimations().forEach(a=>a.pause());
window.setT=function(t){ document.getAnimations().forEach(a=>{a.pause(); a.currentTime=t*1000;}); if(window.hook) window.hook(t); };
window.busy=function(t){ const e=1/30+0.002;
  for (const a of document.getAnimations()) { const c=a.effect.getComputedTiming(); const d=c.delay/1000, end=c.endTime/1000;
    if (t>=d-e && t<=end+e) return true; }
  return window.hookBusy ? !!window.hookBusy(t) : false; };
</script>"""


def page(body, extra_css=""):
    return ('<!doctype html><html lang="ja"><head><meta charset="utf-8"><style>' + CSS + extra_css +
            '</style></head><body><div class="f" id="F">' + body + '</div>' + JS + '</body></html>')


# ---------------- 紙の上の部品（時刻は場面の頭からの秒） ----------------
def ink(text, t, step=0.05, cls="", style=""):
    return f'<span data-ink="{t:.3f},{step}" class="{cls}" style="{style}">{_html.escape(text)}</span>'


def land(text, t, style=""):
    return f'<span class="land" style="animation-delay:{t:.3f}s;{style}">{text}</span>'


def card(kind, body, t, label=None, w=None, anim="pop", fade=None, botsu=None, style=""):
    """kind: fact（墨）/ mi（エイイチの見立て・濃い灰）/ hb（ハコブくん・薄い灰）/ q（問い）"""
    an = {"pop": f"pop .45s ease-out {t:.3f}s forwards", "wipe": f"wipe .45s ease-out {t:.3f}s forwards"}[anim]
    init = "opacity:0;" if anim == "pop" else "clip-path:inset(0 100% 0 0);"
    if fade is not None:
        an += f", fadeto .5s ease {fade:.3f}s forwards"
    lab = f"<small>{label}</small>" if label else ""
    bs = f'<div class="botsu" style="animation-delay:{botsu:.3f}s">ボツ</div>' if botsu is not None else ""
    wd = f"width:{w}px;" if w else ""
    return f'<div class="card k-{kind}" style="{init}{wd}animation:{an};{style}">{lab}{body}{bs}</div>'


def stamp(text, t, style):
    return f'<div class="stamp" style="animation-delay:{t:.3f}s;{style}">{text}</div>'


def tag(text):
    return f'<div class="tag">{text}</div>'


def paper(inner, t_in=0.0, flights=(), style=""):
    """flights: [(飛んでいく秒, 戻る秒), ...] 脱線のときに紙が画面の外へ飛ぶ"""
    an = [f"paperIn .35s ease-out {t_in:.3f}s both"] if t_in is not None else []
    for a, b in flights:
        an.append(f"fly .5s cubic-bezier(.5,0,.9,.4) {a:.3f}s forwards")
        an.append(f"back .5s cubic-bezier(.2,1,.4,1) {b:.3f}s forwards")
    return f'<div class="paper" style="animation:{", ".join(an)};{style}">{inner}</div>'


def dassen(img, t_in, t_out, words="", word_style="top:200px", src_note=""):
    """画面いっぱいの写真・イラスト（t_in〜t_out）。words は大きな一言（<span> は boom で出る）"""
    p = "file://" + os.path.abspath(img)
    out = (f'<div class="dassen" style="background-image:url(\'{p}\');'
           f'animation:dIn .45s ease-out {t_in:.3f}s forwards, dOut .25s linear {t_out:.3f}s forwards"></div>')
    if words:
        spans, k = [], 0
        for part in words.split("|"):
            if part == "<br>":
                spans.append("<br>"); continue
            st = ""
            if part.startswith("!"):
                part, st = part[1:], "color:#ffd84a;"
            spans.append(f'<span style="{st}animation:boom .35s cubic-bezier(.2,1.8,.4,1) {t_in + 0.3 + 0.35 * k:.3f}s forwards, '
                         f'hide .2s linear {t_out - 0.1:.3f}s forwards">{part}</span>')
            k += 1
        out += f'<div class="big" style="{word_style}">{"".join(spans)}</div>'
    if src_note:
        out += (f'<div style="position:absolute;left:40px;top:28px;font-family:\'Noto Sans CJK JP\';font-size:20px;color:#fff;'
                f'text-shadow:0 0 6px #000;opacity:0;animation:show .01s linear {t_in + 0.3:.3f}s forwards, hide .1s linear {t_out:.3f}s forwards">{src_note}</div>')
    return out


def shake_js(windows, el="F", amp=5):
    """判子などで画面を少し揺らす（windows: [(開始秒, 長さ)]）"""
    w = json.dumps(windows)
    return (f'<script>(function(){{const W={w};const p=window.hook;window.hook=function(t){{if(p)p(t);let s="none";'
            f'for(const [a,d] of W){{if(t>=a&&t<a+d){{s="translate("+(Math.sin(t*90)*{amp})+"px,"+(Math.cos(t*70)*{amp * 0.8})+"px)";}}}}'
            f'document.getElementById("{el}").style.transform=s;}};const q=window.hookBusy;window.hookBusy=function(t){{'
            f'if(q&&q(t))return true;for(const [a,d] of W){{if(t>=a-0.05&&t<a+d+0.05)return true;}}return false;}};}})();</script>')


def counter_js(el, t0, t1, v1, dec=1, suffix=""):
    """数え上げ（t0〜t1 で 0→v1）"""
    return (f'<script>(function(){{const p=window.hook;window.hook=function(t){{if(p)p(t);const u=Math.min(1,Math.max(0,(t-{t0})/({t1}-{t0})));'
            f'const v={v1}*u;document.getElementById("{el}").textContent=v.toFixed({dec})+"{suffix}";}};const q=window.hookBusy;'
            f'window.hookBusy=function(t){{if(q&&q(t))return true;return t>={t0}-0.05&&t<={t1}+0.05;}};}})();</script>')


# ---------------- 場面の時刻 ----------------
class Timeline:
    def __init__(self, first=None):
        self.idx = load(first)
        self.total = layout(self.idx)

    def start(self, i):
        return self.idx[i]["start"] / FPS

    def speech(self, i):
        return self.idx[i]["speech"] / FPS

    def end(self, i):
        return (self.idx[i]["start"] + self.idx[i]["frames"]) / FPS


# ---------------- ドット絵のエイイチ ----------------
def fill_teeth(im):
    """顔の中の透明な小さい穴（歯）を白で埋める（v4 の差分は歯が透明）"""
    from scipy import ndimage as nd
    a = np.array(im.convert("RGBA"))
    holes = a[..., 3] < 128
    lab, n = nd.label(holes)
    edge = set(np.unique(np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]]))) | {0}
    for k in range(1, n + 1):
        if k in edge:
            continue
        m = lab == k
        if m.sum() < 80:
            a[m] = (250, 250, 246, 255)
    return Image.fromarray(a)


class Eiichi:
    POSES = {1: "1_腕組み", 2: "2_うなずく", 3: "3_指さす", 4: "4_首をかしげる", 5: "5_ひらめく",
             6: "6_のけぞって驚く", 7: "7_半目でツッコむ", 8: "8_吹き出して笑う"}

    def __init__(self, folder="src/ei"):
        self.raw = {}
        for k, n in self.POSES.items():
            fs = [f for f in os.listdir(folder) if f.startswith(f"エイイチ_{n}_100px")]
            im = Image.open(os.path.join(folder, sorted(fs)[-1])).convert("RGBA")
            self.raw[k] = fill_teeth(im)
        self._cache = {}

    def sprite(self, pose, scale=4, glow=False, crop=1.0):
        key = (pose, scale, glow, crop)
        if key not in self._cache:
            im = self.raw[pose]
            im = im.resize((im.width * scale, im.height * scale), Image.NEAREST)
            if crop < 1.0:
                im = im.crop((0, 0, im.width, int(im.height * crop)))
            if glow:
                pad = 60
                g = Image.new("RGBA", (im.width + 2 * pad, im.height + 2 * pad), (0, 0, 0, 0))
                al = Image.new("L", g.size, 0); al.paste(im.split()[3], (pad, pad))
                al = al.filter(ImageFilter.GaussianBlur(28)).point(lambda v: int(v * 0.35))
                g = Image.merge("RGBA", (Image.new("L", g.size, 245), Image.new("L", g.size, 243), Image.new("L", g.size, 238), al))
                g.alpha_composite(im, (pad, pad))
                im = g
            self._cache[key] = (np.array(im).astype(np.float32), (-60 if glow else 0))
        return self._cache[key]


def blend(dst, src, x, y, alpha=1.0):
    """dst: H×W×3 uint8、src: h×w×4 float（0-255）を (x,y) に重ねる（はみ出しは切る）"""
    h, w = src.shape[:2]
    DH, DW = dst.shape[:2]   # 縦長（ショート）でも使えるように、重ね先の大きさで切る
    x0, y0, x1, y1 = max(0, x), max(0, y), min(DW, x + w), min(DH, y + h)
    if x0 >= x1 or y0 >= y1:
        return
    s = src[y0 - y:y1 - y, x0 - x:x1 - x]
    a = s[..., 3:4] / 255.0 * alpha
    d = dst[y0:y1, x0:x1].astype(np.float32)
    dst[y0:y1, x0:x1] = (d * (1 - a) + s[..., :3] * a + 0.5).clip(0, 255).astype(np.uint8)


def rotate_about(img, deg, pivot, pad=760):
    """img（PIL RGBA）を pivot（img 内の座標）のまわりに回す。返り値: (回した画像, img の左上からのずれ)"""
    c = Image.new("RGBA", (img.width + 2 * pad, img.height + 2 * pad), (0, 0, 0, 0))
    c.paste(img, (pad, pad))
    c = c.rotate(-deg, resample=Image.BICUBIC, center=(pad + pivot[0], pad + pivot[1]))
    bb = c.getbbox()
    if not bb:
        return c, (-pad, -pad)
    return c.crop(bb), (bb[0] - pad, bb[1] - pad)


def ease(u):
    u = min(1.0, max(0.0, u))
    return u * u * (3 - 2 * u)


# ---------------- 字幕 ----------------
SUBCSS = """*{margin:0;padding:0}html,body{width:1920px;height:1080px;background:transparent}
svg{position:absolute;left:0;top:0}
.t{font-family:'Noto Sans CJK JP',sans-serif;font-size:52px;font-weight:800;fill:#fff;stroke:#0D0D0D;stroke-width:11px;stroke-linejoin:round;stroke-linecap:round;paint-order:stroke fill}"""


def fill_holes(path, ink=(13, 13, 13), maxarea=400):
    from scipy import ndimage as _nd
    a = np.array(Image.open(path).convert('RGBA')).astype(np.float32)
    al = a[..., 3] / 255.0
    lab, n = _nd.label(al < 0.98)
    if not n:
        return
    edge = set(np.unique(np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]]))) | {0}
    sz = _nd.sum(np.ones_like(al), lab, range(1, n + 1))
    keep = np.array([0] + [(k not in edge and sz[k - 1] <= maxarea) for k in range(1, n + 1)])
    m = keep[lab].astype(bool)
    if not m.any():
        return
    for c in range(3):
        a[..., c][m] = a[..., c][m] * al[m] + ink[c] * (1 - al[m])
    a[..., 3][m] = 255
    Image.fromarray(a.clip(0, 255).astype(np.uint8)).save(path)


def make_subs(idx, pg):
    """render.py と同じ字幕（中央・白・黒縁・2行まで）。png/sub/{番号}_{枚目}.png"""
    os.makedirs("png/sub", exist_ok=True)
    bad = []
    for o in idx:
        if not o["text"]:
            continue
        for pk, ptxt in enumerate(sub_pages(o["text"])):
            lines = split_lines(ptxt) if ptxt else None
            if lines is None:
                bad.append(f"字幕 {o['i']:03d}: 2行に割れない「{o['text']}」"); continue
            out = f"png/sub/{o['i']:03d}_{pk}.png"
            key = hashlib.md5(repr(lines).encode()).hexdigest()[:10]
            if os.path.exists(out) and os.path.exists(out + ".key") and open(out + ".key").read() == key:
                continue
            svg_lines = ''.join(f'<text x="{SUB_CX}" text-anchor="middle" class="t">{_html.escape(ln)}</text>' for ln in lines)
            pg.set_content(f"""<!doctype html><html><head><meta charset="utf-8"><style>{SUBCSS}</style></head><body>
<svg width="1920" height="1080" viewBox="0 0 1920 1080">{svg_lines}</svg></body></html>""")
            pg.evaluate(f"""() => {{
              const ts=[...document.querySelectorAll('text.t')]; const lh=72; const n=ts.length;
              let fs=52; for (const t of ts){{ const w=t.getComputedTextLength(); if (w>{SUB_MAXW}) fs=Math.min(fs, Math.floor(52*{SUB_MAXW}/w)); }}
              ts.forEach((t,k)=>{{ t.style.fontSize=fs+'px'; t.setAttribute('y', 1080-62-(n-1-k)*lh); }}); }}""")
            pg.screenshot(path=out, omit_background=True)
            fill_holes(out)
            open(out + ".key", "w").write(key)
    if bad:
        sys.exit("\n".join(bad))


def sub_layer(path):
    im = Image.open(path).convert("RGBA")
    bb = im.getbbox()
    return np.array(im.crop(bb)).astype(np.float32), bb[0], bb[1]


# ---------------- 紙の映像を撮る ----------------
def block_hash(b, n):
    return hashlib.md5((b.get("html", "") + repr(b.get("video")) + str(n)).encode()).hexdigest()[:10]


def render_block(b, n, out, pg=None):
    """b: dict(name, html | video=(映像, 開始秒, テロップpng)), n: コマ数 → out（mp4・crf 14）"""
    tmp = out[:-4] + ".part.mp4"
    if "video" in b:
        src, ss, ovl = b["video"]
        ins = ["-ss", f"{ss:.3f}", "-stream_loop", "-1", "-i", src]
        fc = f"[0:v]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,fps={FPS},setsar=1[b]"
        if ovl:
            ins += ["-loop", "1", "-i", ovl]
            fc += ";[b][1:v]overlay[v]"
        else:
            fc += ";[b]null[v]"
        subprocess.run(["ffmpeg", "-v", "error", "-y"] + ins + ["-filter_complex", fc, "-map", "[v]", "-frames:v", str(n),
                        "-c:v", "libx264", "-preset", "ultrafast", "-crf", "14", "-pix_fmt", "yuv420p", "-r", str(FPS), "-an", tmp], check=True)
        os.replace(tmp, out)
        return
    with open("_block.html", "w", encoding="utf-8") as f:
        f.write(b["html"])
    pg.goto("file://" + os.path.abspath("_block.html"))
    pg.evaluate("document.fonts.ready")
    pg.wait_for_timeout(300)
    p = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "image2pipe", "-framerate", str(FPS), "-c:v", "mjpeg", "-i", "-",
                          "-c:v", "libx264", "-preset", "ultrafast", "-crf", "14", "-pix_fmt", "yuv420p", "-r", str(FPS), "-an", tmp],
                         stdin=subprocess.PIPE)
    last, shots = None, 0
    for f in range(n):
        t = f / FPS
        if last is None or pg.evaluate(f"window.busy({t})"):
            pg.evaluate(f"window.setT({t})")
            last = pg.screenshot(type="jpeg", quality=93); shots += 1
        p.stdin.write(last)
    p.stdin.close()
    if p.wait():
        sys.exit(f"紙の映像の書き出しに失敗: {b['name']}")
    os.replace(tmp, out)
    print(f"  {b['name']}: {n}コマ（撮影 {shots}）", flush=True)


def still(b, t, pg, path):
    with open("_block.html", "w", encoding="utf-8") as f:
        f.write(b["html"])
    pg.goto("file://" + os.path.abspath("_block.html"))
    pg.evaluate("document.fonts.ready")
    pg.wait_for_timeout(200)
    pg.evaluate(f"window.setT({t})")
    pg.screenshot(path=path, type="jpeg", quality=90)


# ================= 重ねる（ハコブくん・エイイチ・字幕） =================
class Cast:
    """フレームごとのハコブくんとエイイチの姿を決めて、背景に重ねる"""

    def __init__(self, sc, last_i):
        self.sc, self.idx = sc, sc.tl.idx[:last_i + 1]
        idx = self.idx
        self.total = idx[-1]["start"] + idx[-1]["frames"]
        ei_lines = set(_ei_lines())
        self.ei_lines = ei_lines
        pcm = narration(idx)
        self.pcm = pcm
        # --- ハコブくん ---
        self.hu = hk.Hakobu()
        self.hf = hk.Hakobu(mode="full")
        bb = self.hf.body.getbbox()
        self.hf.w = self.hu.w
        self.hf.h = round((bb[3] - bb[1]) * self.hu.w / (bb[2] - bb[0]))
        self.hf._cache = {}
        tline = [(o["start"], o["start"] + o["frames"], o["start"] + o["speech"], o.get("face", "base"),
                  bool(o["text"]) and o["i"] not in ei_lines) for o in idx]
        mp = pcm.astype(np.float32) / 32768.0
        speak = np.zeros(self.total, bool)
        eispk = np.zeros(self.total, bool)
        for o in idx:
            a0, a1 = int(o["start"] / FPS * SR), int((o["start"] + o["frames"]) / FPS * SR)
            if o["i"] in ei_lines:
                mp[a0:a1] = 0
                eispk[o["start"]:o["start"] + o["speech"]] = True
            elif o["text"]:
                speak[o["start"]:o["start"] + o["speech"]] = True
        self.params = self.hu.animate(tline, mp)
        for t0, t1, f in sc.HK_FACE:   # 文の途中で替える表情
            ex = hk.EXPR[f]
            for i in range(max(0, int(t0 * FPS)), min(self.total, int(t1 * FPS))):
                eyes, brows, mouth, ring, dy = self.params[i]
                if not speak[i]:
                    mouth = ex["rest"]
                elif mouth == "closed" and "closed" in ex:
                    mouth = ex["closed"]
                self.params[i] = (ex["eyes"], ex["brows"], mouth, ring, dy)
        # --- エイイチ: 声に合わせて 4px（ドット1つ）弾む ---
        ei_pcm = pcm.astype(np.float32) / 32768.0
        hop = SR // FPS
        rms = np.array([np.sqrt(np.mean(ei_pcm[i * hop:(i + 1) * hop] ** 2)) if eispk[i] else 0.0 for i in range(self.total)])
        ref = np.percentile(rms[rms > 0.01], 85) if (rms > 0.01).any() else 1.0
        bob = np.zeros(self.total, int)
        for i in range(0, self.total, 3):
            v = -4 if rms[i:i + 3].mean() > 0.45 * ref else 0
            bob[i:i + 3] = v
        self.bob = bob
        self.ei = Eiichi()
        self.subs = {}

    def ei_state(self, f):
        t = f / FPS
        pose = 1
        for a, p in self.sc.EI_POSE:
            if t >= a:
                pose = p
        place = "normal"
        for a, b, k in self.sc.EI_PLACE:
            if a <= t < b:
                place, pa, pb = k, a, b
        x, y, scale, glow, crop = 1490, 680, 4, False, 1.0
        if place == "hide":
            return None
        if place == "big":
            scale, glow, x, y = 5, True, 1920 - 60 - 500, 1080 - 500
        elif place == "buried":
            scale, crop = 3, 0.62
            x, y = 840, 870 - int(300 * 0.62)   # 紙の山の真ん中の黒い穴から顔を出す（字幕より上）
        elif place == "slide":
            pose = 3
            go = ease((t - pa) / 0.7)
            back = ease((t - (pb - 0.5)) / 0.5)
            x = int(1490 + (520 - 1490) * go + (1490 - 520) * back)
            x -= x % 4
        y += self.bob[f] if place in ("normal", "slide") else 0
        return pose, x, y, scale, glow, crop

    def hk_state(self, f):
        """→ (体, dx, dy, 回す角度, 回す中心) 体は upper / full"""
        t = f / FPS
        for m in self.sc.HK_MOVES:
            u = t - m["t0"]
            if m["kind"] == "jump" and 0 <= u < m["dur"]:
                y0 = self.hu.y
                y_peak = H - self.hf.h - 30
                if u < 0.32:
                    k = np.sin(u / 0.32 * np.pi / 2) * 1.12
                elif u < 0.5:
                    k = 1.12 - 0.12 * ease((u - 0.32) / 0.18)
                elif u < m["dur"] - 0.45:
                    k = 1.0 + 0.02 * np.sin((u - 0.5) * 6)
                else:
                    k = 1.0 - ease((u - (m["dur"] - 0.45)) / 0.45)
                ytop = y0 + (y_peak - y0) * k
                rot = -7 * min(1, k) + 2.5 * np.sin(u * 5) * min(1, k)
                return "full", 30 * min(1, k), ytop - self.hf.y, rot, (self.hf.w / 2, self.hf.h)
            if m["kind"] == "reflex" and 0 <= u < m["dur"]:
                e = ease(u / 0.1) * (1 - ease((u - 0.25) / (m["dur"] - 0.25)))
                # 上半身の絵を回すと、下の切り口が斜めに見える（2026-11-02 エイイチの指摘）。全身の絵を、上半身と同じ位置に置いて回す
                return "full", -24 * e, self.hu.y - self.hf.y, -16 * e, (0, self.hf.h)
            if m["kind"] == "fall":   # 全身を出してから、前のめりに倒れる（体が切れないように。2026-11-02 エイイチの指摘）
                y0 = self.hu.y
                ys = H - 14 - self.hf.h          # 全身が見える位置（足もとが画面の下から14px）
                piv = (self.hf.w * 0.95, self.hf.h)
                t1 = m["t1"]
                if 0 <= u < 0.18:
                    k = ease(u / 0.18)
                    return "full", 0, y0 + (ys - y0) * k - self.hf.y, -6 * k, piv
                if 0.18 <= u < 0.5:
                    r = -6 + 98 * ease((u - 0.18) / 0.32)
                    return "full", 0, ys - self.hf.y, r, piv
                if u >= 0.5 and t < t1:
                    r = 88 + 4 * np.exp(-(u - 0.5) * 8) * np.cos((u - 0.5) * 28)
                    return "full", 0, ys - self.hf.y, r, piv
                if t1 <= t < t1 + 0.45:
                    return "full", 0, ys - self.hf.y, 88 * (1 - ease((t - t1) / 0.45)), piv
                if t1 + 0.45 <= t < t1 + 0.75:
                    k = ease((t - t1 - 0.45) / 0.3)
                    return "full", 0, ys + (y0 - ys) * k - self.hf.y, 0, piv
        return "upper", 0, 0, 0, None

    def hk_alpha(self, f):
        t = f / FPS
        for a, b, al in self.sc.HK_ALPHA:
            if a <= t < b:
                return al
        return 1.0

    def sub_at(self, f):
        for o in self.idx:
            if o["start"] <= f < o["start"] + o["frames"]:
                if not o["text"]:
                    return None
                r = f - o["start"]
                for k, a, b in o["parts"]:
                    if a <= r < b:
                        key = (o["i"], k if k is not None else 0)
                        if key not in self.subs:
                            self.subs[key] = sub_layer(f"png/sub/{key[0]:03d}_{key[1]}.png")
                        return self.subs[key]
        return None

    def draw(self, bg, f):
        """bg: H×W×3 uint8（書き換える）"""
        st = self.ei_state(f)
        if st:
            pose, x, y, scale, glow, crop = st
            spr, off = self.ei.sprite(pose, scale, glow, crop)
            blend(bg, spr, x + off, y + off)
        body, dx, dy, rot, pivot = self.hk_state(f)
        hh = self.hu if body == "upper" else self.hf
        img = hh.frame(self.params[f])
        x, y = hh.x + int(round(dx)), hh.y + int(round(dy))
        if rot:
            img, (ox, oy) = rotate_about(img, rot, pivot)
            x, y = x + ox, y + oy
        blend(bg, np.asarray(img).astype(np.float32), x, y, self.hk_alpha(f))
        s = self.sub_at(f)
        if s is not None:
            blend(bg, s[0], s[1], s[2])
        return bg


def _ei_lines():
    import importlib.util as ilu
    sp = ilu.spec_from_file_location("_narr_here", os.path.join(os.getcwd(), "narration.py"))
    nm = ilu.module_from_spec(sp); sp.loader.exec_module(nm)
    return list(getattr(nm, "EI_LINES", []))


def prepare(sc, blocks, pg):
    os.makedirs("png/ovl", exist_ok=True); os.makedirs("blocks", exist_ok=True)
    for k, h in sc.OVERLAYS.items():
        pg.set_content(h); pg.wait_for_timeout(80)
        pg.screenshot(path=f"png/ovl/{k}.png", omit_background=True)
    last_i = blocks[-1]["sents"][-1]
    make_subs(sc.tl.idx[:last_i + 1], pg)


def block_frames(sc, b):
    idx = sc.tl.idx
    a = idx[b["sents"][0]]["start"]
    e = idx[b["sents"][-1]]["start"] + idx[b["sents"][-1]]["frames"]
    return a, e - a


def main():
    args = sys.argv[1:]
    cmd = args[0] if args else "sheet"
    import importlib
    sc = importlib.import_module("scenes_kabe")
    from playwright.sync_api import sync_playwright
    blocks = sc.BLOCKS
    if "--first-blocks" in args:
        blocks = blocks[:int(args[args.index("--first-blocks") + 1])]
    names = [a for a in args[1:] if not a.startswith("--") and not a.isdigit()]
    with sync_playwright() as p:
        br = p.chromium.launch()
        pg = br.new_page(viewport={"width": W, "height": H})
        prepare(sc, blocks, pg)
        if cmd == "sheet":
            cast = Cast(sc, blocks[-1]["sents"][-1])
            os.makedirs("png/sheet", exist_ok=True)
            shots = []
            for b in blocks:
                if names and b["name"] not in names:
                    continue
                a, n = block_frames(sc, b)
                ts = sc.SHEET.get(b["name"], [n / FPS - 0.4])
                for k, t in enumerate(ts):
                    f = min(a + int(t * FPS), a + n - 1)
                    path = f"png/sheet/{b['name']}_{k}.jpg"
                    if "video" in b:
                        src, ss, ovl = b["video"]
                        dur = float(subprocess.check_output(["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", src]))
                        subprocess.run(["ffmpeg", "-v", "error", "-y", "-ss", f"{(ss + t) % (dur - 0.2):.2f}", "-i", src, "-frames:v", "1",
                                        "-vf", "scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080", path], check=True)
                        im = Image.open(path).convert("RGBA")
                        im.alpha_composite(Image.open(ovl).convert("RGBA"))
                        im.convert("RGB").save(path)
                    else:
                        still(b, t, pg, path)
                    bg = np.array(Image.open(path).convert("RGB"))
                    Image.fromarray(cast.draw(bg, f)).save(path, quality=88)
                    shots.append((b["name"], k, f / FPS, path))
            # 一覧
            cols, tw = 4, 480
            th = tw * 9 // 16
            rows = (len(shots) + cols - 1) // cols
            from PIL import ImageDraw, ImageFont
            fnt = ImageFont.truetype("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", 22)
            sheet = Image.new("RGB", (cols * tw, rows * (th + 34)), (240, 238, 232))
            d = ImageDraw.Draw(sheet)
            for j, (nm, k, t, path) in enumerate(shots):
                x, y = (j % cols) * tw, (j // cols) * (th + 34)
                sheet.paste(Image.open(path).resize((tw - 6, th - 4)), (x + 3, y + 32))
                d.text((x + 8, y + 4), f"{nm}  {int(t // 60)}:{t % 60:04.1f}", font=fnt, fill=(30, 30, 30))
            sheet.save("一覧_場面.jpg", quality=85)
            print("一覧_場面.jpg", len(shots))
        if cmd in ("render", "build"):
            for b in blocks:
                if names and b["name"] not in names:
                    continue
                a, n = block_frames(sc, b)
                out = f"blocks/{b['name']}_{block_hash(b, n)}.mp4"
                b["mp4"] = out
                if not os.path.exists(out):
                    render_block(b, n, out, pg)
        br.close()
    if cmd != "build":
        return
    for b in blocks:
        a, n = block_frames(sc, b)
        b["mp4"] = f"blocks/{b['name']}_{block_hash(b, n)}.mp4"
    last_i = blocks[-1]["sents"][-1]
    cast = Cast(sc, last_i)
    idx = cast.idx
    write_wav("narration.wav", cast.pcm)
    audio_in = "narration.wav"
    if os.path.exists("sound.py"):
        from audio_mix import mix
        audio_in = mix(idx, cast.pcm)
    total = cast.total
    enc = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-",
                            "-i", audio_in, "-map", "0:v", "-map", "1:a", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
                            "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-c:a", "aac", "-b:a", "192k", "-ar", "48000",
                            "-movflags", "+faststart", "-frames:v", str(total), "out.mp4"], stdin=subprocess.PIPE)
    f = 0
    for b in blocks:
        a, n = block_frames(sc, b)
        dec = subprocess.Popen(["ffmpeg", "-v", "error", "-i", b["mp4"], "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE)
        for k in range(n):
            raw = dec.stdout.read(W * H * 3)
            if len(raw) < W * H * 3:
                sys.exit(f"紙の映像が短い: {b['name']} {k}/{n}")
            bg = np.frombuffer(raw, np.uint8).reshape(H, W, 3).copy()
            enc.stdin.write(cast.draw(bg, a + k).tobytes())
            f += 1
            if f % 900 == 0:
                print(f"  {f}/{total}", flush=True)
        dec.stdout.close(); dec.wait()
    enc.stdin.close()
    if enc.wait():
        sys.exit("書き出しに失敗")
    # 字幕 SRT
    def ts(fr):
        ms = round(fr / FPS * 1000); h_, ms = divmod(ms, 3600000); m_, ms = divmod(ms, 60000); s_, ms = divmod(ms, 1000)
        return f"{h_:02d}:{m_:02d}:{s_:02d},{ms:03d}"
    srt, k = [], 1
    for o in idx:
        if not o["text"]:
            continue
        pages_ = sub_pages(o["text"])
        for (pk, a_, b_), ptxt in zip(o["parts"], pages_):
            end = min(b_, o["speech"]) if pk == len(pages_) - 1 else b_
            srt.append(f"{k}\n{ts(o['start'] + a_)} --> {ts(o['start'] + end)}\n{ptxt}\n"); k += 1
    open("out.srt", "w", encoding="utf-8").write("\n".join(srt))
    json.dump(idx, open("timeline.json", "w"), ensure_ascii=False)
    for crf in (29, 32, 35):
        subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", "out.mp4", "-c:v", "libx264", "-preset", "medium", "-crf", str(crf),
                        "-c:a", "aac", "-b:a", "112k", "-movflags", "+faststart", "out_確認用.mp4"], check=True)
        mb = os.path.getsize("out_確認用.mp4") / 1e6
        if mb <= 30:
            break
    print(f"frames {total} = {total / FPS:.1f}s  確認用 {mb:.1f}MB (crf {crf})")


if __name__ == "__main__":
    main()
