# -*- coding: utf-8 -*-
"""考察回「壁打ちの型」の縦長ショート（1080×1920）・2版。
2026-11-02 エイイチの指摘で作り直した:
  「おれが上半身で、ハコブくんが全身は変」→ エイイチも全身のドット絵（src/ei/エイイチ_全身_*_170px_v1.png・Gemini で写真の服装から作った）
  「この始まりだとすぐ離脱しそう」「宇宙・紙の山・引退式の背景を使って、ハコブくんが大きく動く所から始める」
    → 最初の数秒は、画面いっぱいの絵の前で、大きなハコブくん（高さ900）が動く。本題に入ったら、絵を暗くして紙（考察ノート）を出し、
      ハコブくんは左下の全身（高さ400）に戻る
画面の割り振りは video_rules.md 9-2: 0〜150 何も置かない／170〜440 見出し／480〜1130 紙／左下 ハコブくん・右下 エイイチ（足もと y=1640）／字幕は2人の間の上
使い方: python3 kabe_short.py [番号 ...]
"""
import os, sys, json, wave, subprocess, html as _html
import numpy as np
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import kabe_kit as K
from kabe_kit import ink, land, card, counter_js, shake_js, ease, blend, rotate_about, hk, FPS, SR
sys.path.insert(1, K.BASE)
import short_kit as SK
from scene_kit import LOGO

W, H = 1080, 1920
SANS = "font-family:'Noto Sans CJK JP',sans-serif;"
FOOT = 1880                     # 2人の足もと（下の方まで使ってよい・2026-11-02 エイイチ）
SUB_MID = 1150                  # 字幕の真ん中
SUB_CX, SUB_MAXC = 540, 11       # 字幕は画面の真ん中（2026-11-02 エイイチの指摘で 470 から直した）・1行11字
EI_SCALE, EI_X = 4.5, None         # エイイチの全身（170px の4.5倍＝高さ765・右端に寄せる）。身長はエイイチ 1700mm・ハコブくん 1150mm（2026-11-02 エイイチ）
HK_SMALL_H, HK_X = 515, 10      # ふだんのハコブくん（全身）。765 × 1150/1700
HK_BIG_H = 900                  # 最初の数秒の大きなハコブくん
EI_POSES = {1: "立つ", 2: "腕組み", 3: "驚く", 4: "笑う"}

VCSS = """
html, body { width:1080px; height:1920px; }
.f { width:1080px; height:1920px; }
.vhead { position:absolute; left:60px; right:60px; top:170px; height:270px; display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; z-index:5; }
.vhead img { height:54px; align-self:flex-start; margin-bottom:18px; filter:invert(1) drop-shadow(0 0 6px rgba(0,0,0,.8)); opacity:.9; }
.vhead .q { font-family:'Noto Sans CJK JP',sans-serif; font-size:84px; font-weight:900; line-height:1.2; color:#f5f3ee; text-shadow:0 0 18px rgba(0,0,0,.9), 0 4px 0 #141311; }
.vpaper { position:absolute; left:100px; top:480px; width:880px; height:590px; background:#f5f3ee; box-shadow:0 30px 60px rgba(0,0,0,.45); opacity:0; }
.vpaper .card { font-size:40px; }
.vpaper .card small { font-size:24px; }
.vstack { position:absolute; left:0; right:0; top:40px; bottom:40px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:22px; }
.bgimg { position:absolute; inset:0; background-repeat:no-repeat; background-color:#fff; }
@keyframes veil { to { opacity:.74; } }
@keyframes pIn { from { opacity:0; transform:translateY(60px) rotate(-1.5deg); } to { opacity:1; transform:none; } }
.vbig { position:absolute; left:0; right:0; text-align:center; font-family:'Noto Sans CJK JP',sans-serif; font-weight:900; color:#fff; line-height:1.15;
        -webkit-text-stroke:5px #141311; paint-order:stroke fill; text-shadow:0 10px 0 #c4472a; z-index:6; }
.vbig span { display:inline-block; opacity:0; }
"""


def vpage(head, body):
    return K.page(body + f'<div class="vhead"><img src="{LOGO}"><div class="q">{head}</div></div>', VCSS)


def bg(img, size_h, pos_x, pos_y, t_veil):
    p = "file://" + os.path.abspath(img)
    return (f'<div class="bgimg" style="background-image:url(\'{p}\');background-size:auto {size_h}px;background-position:{pos_x}px {pos_y}px"></div>'
            f'<div style="position:absolute;inset:0;background:#141311;opacity:0;animation:veil .5s ease {t_veil:.3f}s forwards"></div>'
            '<div style="position:absolute;right:100px;top:446px;font-family:\'Noto Sans CJK JP\';font-size:22px;color:#fff;opacity:.85;text-shadow:0 0 6px #000;z-index:7">絵は生成AI（Gemini）で作成</div>')


def paper(inner, t_in):
    return f'<div class="vpaper" style="animation:pIn .45s cubic-bezier(.2,1.2,.4,1) {t_in:.3f}s forwards">{inner}</div>'


def grp(inner, t_in=0.0, t_out=None):
    an = f"show .01s linear {t_in:.3f}s forwards" + (f", hide .2s linear {t_out:.3f}s forwards" if t_out is not None else "")
    return f'<div class="vstack" style="opacity:0;animation:{an}">{inner}</div>'


def line(text, t, size=40, weight=700, step=0.05, style=""):
    return f'<div style="{SANS}font-size:{size}px;font-weight:{weight};text-align:center;{style}">{ink(text, t, step)}</div>'


def words(rows, t0, t_out, top, size=130, gap=0.32):
    """大きな一言を1行ずつ叩きつける。rows は [(文字, 色)]"""
    out = []
    for k, (txt, col) in enumerate(rows):
        out.append(f'<span style="color:{col};animation:boom .32s cubic-bezier(.2,1.8,.4,1) {t0 + gap * k:.3f}s forwards, hide .2s linear {t_out:.3f}s forwards">{txt}</span>')
    return f'<div class="vbig" style="top:{top}px;font-size:{size}px;transform:rotate(-4deg)">{"<br>".join(out)}</div>'


def bigword(text, t, t_out, top, size=300, color="#c4472a", rot=-8):
    return (f'<div class="big" style="top:{top}px;font-size:{size}px;color:{color};-webkit-text-stroke:10px #fff;paint-order:stroke fill;'
            f'text-shadow:0 14px 0 #141311;transform:rotate({rot}deg);z-index:6"><span style="animation:boom .3s cubic-bezier(.2,1.8,.4,1) {t:.3f}s forwards, '
            f'hide .2s linear {t_out:.3f}s forwards">{text}</span></div>')


WHITE, GOLD = "#ffffff", "#ffd84a"


# ====================== 3本の中身（T(k, 秒) は k 番目の文の頭から。k=-1 は動画の頭） ======================
# 2026-11-02「どこまでいじっていい」用に書き替えた（書き写した元: 頭脳は脳のように分かれる/kabe_short.py）
FORM_CSS = """<style>
.vform { width:700px; background:#fffdf8; border:3px solid #22201c; padding:18px 30px 22px; font-family:'Noto Sans CJK JP',sans-serif; color:#22201c; text-align:left; }
.vform h3 { font-family:'Noto Serif CJK JP',serif; font-size:52px; font-weight:900; text-align:center; letter-spacing:.2em; }
.vform .it { font-size:36px; font-weight:700; padding:6px 0; border-top:2px solid #e2ddd2; }
</style>"""


def short_kaizo(T, S):
    tp = T(1, 0.0)   # エイイチの「誰の責任？」で、絵を暗くして紙を出す
    return vpage("改造したら、<br>責任は誰？", (
        bg("src/bg/自分で改造.jpg", 1920, -2333, 0, tp)
        + words([("僕を、", WHITE), ("改造して", GOLD)], T(0, 0.2), tp, 560, size=140, gap=0.5)
        + paper(FORM_CSS + grp(f'<div class="vform"><h3>改造申請書</h3><div class="it">☑ 速さの上限を外す</div><div class="it">☑ 夜も走る</div></div>'
                    + card("mi", ink("それ、事故が起きたら、誰の責任？", T(1, 0.3), 0.06), T(1, 0.1), "エイイチ", w=760, anim="wipe"),
                    tp + 0.1, T(3, 0.0))
                + grp(card("fact", ink("EU：想定外の改変で危険を増やした人は、メーカーと同じ義務", T(3, 0.4), 0.04), T(3, 0.2), None, w=780)
                      + card("fact", ink("日本：仕事として加工して渡した業者も、責任を負う側", T(3, 3.0), 0.04), T(3, 2.8), None, w=780)
                      + line("法律の話は、調べた範囲の理解です", T(3, 0.4), size=26, weight=500, style="color:#5a564f"), T(3, 0.05)), tp)
        + bigword("却下", T(2, 0.2), T(3, 0.0), 640, size=300)
        + shake_js([(T(2, 0.5), 0.3)])))


def short_robokan(T, S):
    tp = T(1, 0.0)
    return vpage("ロボットにも、<br>車検を作ろう", (
        bg("src/bg/ロボ検.jpg", 1920, -2333, 0, tp)
        + words([("ロボ検、", WHITE), ("開幕。", GOLD)], T(0, 2.6), tp, 600, size=150, gap=0.5)
        + paper(grp(card("hb", "ロボットにも車検を。名前は、ロボ検", T(0, 0.3), "ハコブくん", w=760, botsu=T(2, 0.3))
                    + card("mi", ink("誰が検査するの、それ", T(1, 0.2), 0.07), T(1, 0.05), "エイイチ", w=600, anim="wipe")
                    + card("fact", ink("屋内のロボットには、決まった手続きがない。改造した人が、安全を自分で説明する", T(3, 0.4), 0.035), T(3, 0.2), "だから", w=780), tp + 0.1), tp)
        + bigword("誰も<br>いない", T(2, 0.4), T(3, 0.0), 560, size=220)
        + shake_js([(T(2, 0.7), 0.3)])))


def short_kakin(T, S):
    tp = T(0, 3.2)
    return vpage("同じ体のまま、<br>お金で速くなる", (
        bg("src/bg/課金で解放.jpg", 1920, -1900, 0, tp)
        + words([("課金で", WHITE), ("解放。", GOLD)], T(0, 0.6), tp, 600, size=150, gap=0.5)
        + paper(grp(line("Model Y（2025年・米国）", tp + 0.3, size=34, weight=700)
                    + f'<div class="num" style="font-size:170px;line-height:1.05"><span style="font-size:80px">約$</span><span id="c2k">0</span></div>'
                    + line("部品は替えずに、ソフトの更新だけで速くなる", T(0, 4.6), size=32, weight=500)
                    + card("mi", ink("それ、ロボットでもやりそう", T(2, 0.2), 0.06), T(2, 0.05), "エイイチ", w=600, anim="wipe")
                    + f'<div class="row2" style="gap:24px">' + card("hb", "払いますか？", T(3, 0.05), "ハコブくん", w=300)
                    + card("mi", '<b style="font-size:54px">払います</b>', T(4, 0.0), "エイイチ", w=300, anim="wipe") + '</div>', tp + 0.1), tp)
        + counter_js("c2k", T(0, 4.0), T(0, 6.0), 2000, dec=0)
        + bigword("即答", T(5, 0.0), T(5, 99), 1300, size=260)
        + shake_js([(T(5, 0.3), 0.3)])))


# segs: (本編の文番号, 後ろの間[, 頭を切る秒, 字幕の文])
# big: 大きなハコブくんの出る間（end の文の頭で、左下の大きさへ）。kind: rise（下から飛び出す）/ hop（跳ねて喜ぶ）
SHORTS = [
    dict(no=1, name="改造してください", fn=short_kaizo, lead=0.5,
         segs=[(1, 0.7), (2, 0.4), (3, 0.6), (4, 1.2)],
         big=dict(kind="spin", end=1),
         face=[(-1, 0.0, 3.0, "joy"), (2, 0.0, 2.5, "trouble")],
         pose=[(-1, 0.0, 3), (1, 0.0, 1), (2, 0.0, 3), (3, 0.0, 2)], ei_rise=None,
         se=[(-1, 0.0, "se_whoosh", -10), (-1, 0.4, "se_rise", -8), (-1, 1.25, "se_pop", -4), (0, 0.2, "se_pop", -10), (0, 0.7, "se_pop", -10), (2, 0.2, "se_pop", -6), (3, 0.0, "se_whoosh", -12)]),
    dict(no=2, name="ロボ検", fn=short_robokan, lead=0.4,
         segs=[(39, 0.5), (40, 0.4), (41, 0.6), (42, 1.2, 0.0, "決まった手続きが｜ない分、改造した人が、｜安全を自分で｜説明することに｜なります。")],
         big=dict(kind="dash", end=1),
         face=[(-1, 0.0, 1.0, "surprise"), (-1, 1.0, 2.0, "trouble"), (0, 2.4, 2.5, "joy"), (2, 0.0, 2.5, "dot")],
         pose=[(-1, 0.0, 3), (1, 0.0, 2), (2, 0.0, 4), (3, 0.0, 1)], ei_rise=(1, 0.0),
         se=[(-1, 0.0, "se_whoosh", -6), (-1, 0.3, "se_backbeep", -10), (0, 2.6, "se_pop", -10), (0, 3.1, "se_pop", -8), (1, 0.0, "se_rise", -10), (2, 0.4, "se_pop", -6)]),
    dict(no=3, name="課金で解放", fn=short_kakin, lead=0.5,
         segs=[(58, 0.5, 0.0, "車のModel Yでは、2025年に、｜部品を替えずに、｜ソフトの更新だけで速くなる、｜約2,000ドルの｜有料の更新が出ています。"), (59, 0.4), (60, 0.3, 0.0, "それ、ロボットでも｜やりそうだね。"), (61, 0.2), (62, 0.3), (63, 1.2)],
         big=dict(kind="frenzy", end=1),
         face=[(-1, 0.0, 1.0, "surprise"), (-1, 1.0, 8.5, "joy"), (5, 0.0, 1.5, "wink")],
         pose=[(-1, 0.0, 3), (1, 0.0, 3), (2, 0.0, 1), (3, 0.0, 2), (4, 0.0, 4), (5, 0.0, 4)], ei_rise=None,
         se=[(-1, 0.0, "se_rise", -8), (0, 0.6, "se_pop", -10), (0, 1.1, "se_pop", -8), (5, 0.0, "se_pop", -6)] + [(-1, 0.45 + 0.37 * k, "se_pop", -18) for k in range(7)]
            + [(0, 4.0 + 0.4 * k, "se_tick", -16) for k in range(5)]),
]


def vpages10(text):
    """字幕の枚（1行10字・3行まで）。short_kit.wrap の折り方で"""
    out = []
    for part in text.split("｜"):
        if not part:
            continue
        ls = SK.wrap(part, SUB_MAXC)
        if ls is None:
            out.append(None); continue
        while ls:
            n = len(ls)
            k = 3 if n == 3 or n >= 5 else min(n, 2)
            out.append("\n".join(ls[:k])); ls = ls[k:]
    return out


def build(sh):
    idx_main = json.load(open("tts/index.json"))
    ei_main = set(K._ei_lines())
    lead = round(sh.get("lead", 0) * FPS)
    segs, t = [], lead
    for k, sg in enumerate(sh["segs"]):
        i, pause = sg[0], sg[1]
        trim = sg[2] if len(sg) > 2 else 0.0
        o = idx_main[i]
        dur = o["dur"] - trim
        frames = round((dur + pause) * FPS)
        segs.append(dict(k=k, i=i, trim=trim, text=sg[3] if len(sg) > 3 else o["text"], face=o.get("face", "base"),
                         start=t, speech=round(dur * FPS), frames=frames, ei=i in ei_main))
        t += frames
    total = t
    if total / FPS > 30.5:
        sys.exit(f"ショート{sh['no']} が {total / FPS:.1f}秒あります（30秒以内）")
    Tf = lambda k, off=0.0: (segs[k]["start"] / FPS if k >= 0 else 0.0) + off
    Sf = lambda k: segs[k]["speech"] / FPS
    html = sh["fn"](Tf, Sf)
    folder = f"ショート/{sh['no']:02d}_{sh['name']}"
    os.makedirs(folder + "/png", exist_ok=True)
    # --- 声 ---
    pcm = np.zeros(total * SR // FPS + SR, np.int16)
    for s in segs:
        with wave.open(f"tts/{s['i']:03d}.wav") as w:
            a = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
        a = a[int(s["trim"] * SR):][:s["frames"] * SR // FPS]
        if s["trim"]:
            a = (a * np.minimum(1, np.arange(len(a)) / (0.02 * SR))).astype(np.int16)   # 切った頭を短くなじませる
        s0 = s["start"] * SR // FPS
        pcm[s0:s0 + len(a)] = a
    pcm = pcm[:total * SR // FPS]
    # --- 字幕と紙の映像 ---
    from playwright.sync_api import sync_playwright
    SUBCSS = (f"*{{margin:0;padding:0}}html,body{{width:{W}px;height:{H}px;background:transparent}}svg{{position:absolute;left:0;top:0}}"
              f".t{{font-family:'Noto Sans CJK JP',sans-serif;font-size:{SK.SUB_FS}px;font-weight:800;fill:#fff;stroke:#0D0D0D;stroke-width:12px;stroke-linejoin:round;paint-order:stroke fill}}")
    subs = []
    with sync_playwright() as p:
        br = p.chromium.launch()
        pg = br.new_page(viewport={"width": W, "height": H})
        for s in segs:
            pages_ = vpages10(s["text"])
            n, tot, acc = len(pages_), sum(len(x or "") for x in pages_), 0
            for pk, ptxt in enumerate(pages_):
                if ptxt is None:
                    sys.exit(f"字幕が折れない: {s['text']}")
                svg = ''.join(f'<text x="{SUB_CX}" text-anchor="middle" class="t">{_html.escape(ln)}</text>' for ln in ptxt.split("\n"))
                pg.set_content(f'<!doctype html><html><head><meta charset="utf-8"><style>{SUBCSS}</style></head><body><svg width="{W}" height="{H}">{svg}</svg></body></html>')
                pg.evaluate(f"""() => {{ const ts=[...document.querySelectorAll('text.t')]; const n=ts.length; let fs={SK.SUB_FS};
                  for (const t of ts) {{ const w=t.getComputedTextLength(); if (w>{SK.SUB_MAXW}) fs=Math.min(fs, Math.floor({SK.SUB_FS}*{SK.SUB_MAXW}/w)); }}
                  ts.forEach((t,k)=>{{ t.style.fontSize=fs+'px'; t.setAttribute('y', Math.round({SUB_MID}+(k-(n-1)/2)*{SK.SUB_LH}+fs*0.36)); }}); }}""")
                path = f"{folder}/png/sub_{s['k']}_{pk}.png"
                pg.screenshot(path=path, omit_background=True)
                K.fill_holes(path)
                a = s["start"] + round(s["speech"] * acc / max(1, tot))
                acc += len(ptxt)
                b = s["start"] + (s["frames"] if pk == n - 1 else round(s["speech"] * acc / max(1, tot)))
                subs.append((a, b, K.sub_layer(path)))
        mp4 = f"{folder}/_paper.mp4"
        K.render_block(dict(name=sh["name"], html=html), total, mp4, pg)
        br.close()

    # --- ハコブくん: 全身。最初は大きく、本題で左下の大きさへ ---
    def full(hh):
        o = hk.Hakobu(mode="full")
        bb = o.body.getbbox()
        o.h = hh; o.w = round((bb[2] - bb[0]) * hh / (bb[3] - bb[1])); o._cache = {}
        return o
    hs, hb = full(HK_SMALL_H), full(HK_BIG_H)
    tline = [(s["start"], s["start"] + s["frames"], s["start"] + s["speech"], s["face"], not s["ei"]) for s in segs]
    if lead:
        tline = [(0, lead, lead, "base", False)] + tline
    mp = pcm.astype(np.float32) / 32768.0
    speak = np.zeros(total, bool); eispk = np.zeros(total, bool)
    for s in segs:
        a0, a1 = s["start"] * SR // FPS, (s["start"] + s["frames"]) * SR // FPS
        if s["ei"]:
            mp[a0:a1] = 0; eispk[s["start"]:s["start"] + s["speech"]] = True
        else:
            speak[s["start"]:s["start"] + s["speech"]] = True
    params = hs.animate(tline, mp)
    for k, a, d, f in sh["face"]:
        ex = hk.EXPR[f]
        for i in range(int(Tf(k, a) * FPS), min(total, int((Tf(k, a) + d) * FPS))):
            eyes, brows, mouth, ring, dy = params[i]
            if not speak[i]:
                mouth = ex["rest"]
            params[i] = (ex["eyes"], ex["brows"], mouth, ring, dy)
    t_end_big = Tf(sh["big"]["end"])
    SHR = 0.5   # 小さくなるのにかける秒

    def hk_big(t):
        """最初の大きなハコブくんの動き → (足もとの x, 足もと y, 角度, 横の伸び, 体の真ん中で回すか)
        2026-11-02 エイイチ「入りのハコブくんの動きをもっと大げさに、動画ごとにいろんな動きでコミカルに」"""
        kind = sh["big"]["kind"]
        if kind == "rise":
            k = 1.15 * np.sin(min(1, t / 0.45) * np.pi / 2) if t < 0.45 else 1.0 + 0.15 * np.exp(-(t - 0.45) * 6) * np.cos((t - 0.45) * 14)
            return 540 + 40 * np.sin(t * 1.1), 1990 + (1900 - 1990) * k + 14 * np.sin(t * 2.2), 6 * np.sin(t * 1.3), 1.0, False
        if kind == "hop":
            ph = (t * 1.9) % 1.0
            by = 1900 - 160 * 4 * ph * (1 - ph)
            if t < 0.3:
                by = 1920 + (by - 1920) * ease(t / 0.3)
            return 380 + 30 * np.sin(t * 2.0), by, 8 * np.sin(t * 3.8), 1.0, False
        if kind == "spin":     # 左から転がり込んで、高く跳んで1回転、どすんと着地してつぶれ、ぺこぺこお願いする
            if t < 0.35:
                u = ease(t / 0.35)
                return -400 + 900 * u, 1900, 25 * (1 - u), 1.0, False
            if t < 1.25:
                u = (t - 0.35) / 0.9
                return 500 + 60 * u, 1900 - 620 * 4 * u * (1 - u), 360 * ease(u), 1.0 - 0.12 * np.sin(u * np.pi), True
            if t < 1.6:
                u = (t - 1.25) / 0.35
                return 560, 1900, 0.0, 1.0 + 0.35 * np.exp(-u * 5) * np.cos(u * 12), False
            u = t - 1.6
            bow = max(0.0, np.sin(u * 7.5))   # 前に深くおじぎ（お願いします）
            return 560 + 10 * np.sin(u * 15), 1900 - 30 * abs(np.sin(u * 7.5)), 24 * bow, 1.0 + 0.05 * bow, False
        if kind == "dash":     # 右から猛ダッシュ → 行き過ぎて急ブレーキ → 汗をかいてガタガタ震える
            if t < 0.3:
                u = ease(t / 0.3)
                return 1500 - 1250 * u, 1900, -14 * u, 1.0 + 0.25 * u, False
            if t < 0.9:
                u = (t - 0.3) / 0.6
                return 250 + 290 * ease(u), 1900, -14 + 40 * np.exp(-u * 4) * np.sin(u * 14), 1.25 - 0.25 * ease(u), False
            u = t - 0.9
            shake = 1.0 if u < 1.5 else 0.35
            return 540 + 16 * shake * np.sin(u * 70), 1900 - 18 * abs(np.sin(u * 9)), 5 * shake * np.sin(u * 47), 1.0, False
        if kind == "frenzy":   # 下から飛び出して、お金の話に大興奮。左右に大きく揺れて、ぴょんぴょん跳ねる
            if t < 0.45:
                k = 1.2 * np.sin(min(1, t / 0.45) * np.pi / 2)
                return 540, 2050 - 150 * k, 0.0, 1.0, False
            u = t - 0.45
            hop = abs(np.sin(u * 8.5))
            return 540 + 90 * np.sin(u * 4.2), 1900 - 120 * hop, 22 * np.sin(u * 8.4), 1.0 + 0.15 * (1 - hop) ** 3, False
        raise ValueError(kind)

    def hk_place(t):
        """→ (高さ, 足もとの中心 x, 足もと y, 角度, 横の伸び, 体の真ん中で回すか)"""
        sx, sy = HK_X + hs.w / 2, FOOT
        if t >= t_end_big + SHR:
            return HK_SMALL_H, sx, sy, 0.0, 1.0, False
        bx, by, rot, st, ctr = hk_big(t)
        if t < t_end_big:
            return HK_BIG_H, bx, by, rot, st, ctr
        u = ease((t - t_end_big) / SHR)
        return HK_BIG_H + (HK_SMALL_H - HK_BIG_H) * u, bx + (sx - bx) * u, by + (sy - by) * u, rot * (1 - u), 1.0 + (st - 1.0) * (1 - u), False

    # --- エイイチ（全身・2倍） ---
    sprites = {}
    for k_, nm in EI_POSES.items():
        im = Image.open(f"src/ei/エイイチ_全身_{nm}_170px_v2.png").convert("RGBA")
        im = im.resize((round(im.width * EI_SCALE), round(im.height * EI_SCALE)), Image.NEAREST)
        # 暗い背景に服が沈むので、うすい光の縁を付ける（本編の暗い場面と同じ）
        from PIL import ImageFilter
        pad = 20
        g = Image.new("L", (im.width + 2 * pad, im.height + 2 * pad), 0)
        g.paste(im.split()[3], (pad, pad))
        g = g.filter(ImageFilter.GaussianBlur(7)).point(lambda v: int(min(255, v * 1.6) * 0.45))
        lay = Image.merge("RGBA", (Image.new("L", g.size, 235), Image.new("L", g.size, 232), Image.new("L", g.size, 225), g))
        lay.alpha_composite(im, (pad, pad))
        sprites[k_] = np.asarray(lay.crop((pad, 0, pad + im.width, pad + im.height))).astype(np.float32)
    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(total)])
    ref = np.percentile(rms[rms > 0.01], 85) if (rms > 0.01).any() else 1.0
    bob = np.zeros(total, int)
    for i in range(0, total, 3):
        bob[i:i + 3] = -EI_SCALE if rms[i:i + 3].mean() > 0.45 * ref else 0
    poses = sorted((Tf(k, a), p_) for k, a, p_ in sh["pose"])

    def ei_dy(t):
        """ei_rise: その時刻まで画面の下に隠れていて、下から出てくる"""
        if not sh.get("ei_rise"):
            return 0
        t0 = Tf(*sh["ei_rise"])
        if t < t0:
            return 760
        return int(760 * (1 - ease((t - t0) / 0.35)))

    # --- 音 ---
    voice = pcm.astype(np.float32) / 32768.0
    ref_v = np.percentile(np.abs(voice[voice != 0]), 90) if (voice != 0).any() else 0.1
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", "sound/bgm_明るいB_2.mp3", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"], capture_output=True, check=True).stdout
    bgm = np.frombuffer(raw, np.int16).astype(np.float32) / 32768.0
    bgm = np.tile(bgm, int(np.ceil(len(voice) / len(bgm))) + 1)[:len(voice)]
    bgm *= ref_v / (np.percentile(np.abs(bgm), 90) + 1e-6) * 10 ** (-24 / 20)
    fade = np.ones(len(voice)); nfo = int(1.0 * SR); fade[-nfo:] = np.linspace(1, 0, nfo)
    mix = voice + bgm * fade
    for k, off, name, lv in sh["se"]:
        raw = subprocess.run(["ffmpeg", "-v", "error", "-i", os.path.join(K.BASE, "sound", name + ".wav"), "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"], capture_output=True, check=True).stdout
        se = np.frombuffer(raw, np.int16).astype(np.float32) / 32768.0
        se *= ref_v / (np.abs(se).max() + 1e-6) * 10 ** (lv / 20)
        s0 = int(Tf(k, off) * SR)
        n = min(len(se), len(mix) - s0)
        if n > 0:
            mix[s0:s0 + n] += se[:n]
    mix *= 0.98 / max(0.98, np.abs(mix).max())
    K.write_wav(f"{folder}/_mix.wav", (mix * 32767).astype(np.int16))

    # --- 重ねる ---
    out = f"{folder}/ショート{sh['no']}_{sh['name']}_版2.mp4"
    enc = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-",
                            "-i", f"{folder}/_mix.wav", "-map", "0:v", "-map", "1:a", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
                            "-af", "loudnorm=I=-14:TP=-1.5:LRA=11", "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-movflags", "+faststart",
                            "-frames:v", str(total), out], stdin=subprocess.PIPE)
    dec = subprocess.Popen(["ffmpeg", "-v", "error", "-i", mp4, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE)
    for f in range(total):
        t = f / FPS
        bgf = np.frombuffer(dec.stdout.read(W * H * 3), np.uint8).reshape(H, W, 3).copy()
        pose = 1
        for a, p_ in poses:
            if t >= a:
                pose = p_
        spr = sprites[pose]
        blend(bgf, spr, W - spr.shape[1] - 12, FOOT - spr.shape[0] + bob[f] + ei_dy(t))   # エイイチは右端に寄せる
        hh_ = sh.get("hk_hide")   # 絵にハコブくんが描いてある間は、重ねない（2026-11-02 どこまでいじっていい）
        if hh_ and Tf(*hh_[0]) <= t < Tf(*hh_[1]):
            for a, b, (sl, sx_, sy_) in subs:
                if a <= f < b:
                    blend(bgf, sl, sx_, sy_)
            enc.stdin.write(bgf.tobytes())
            continue
        size, cx, fy, rot, st, ctr = hk_place(t)
        img = (hb if size > HK_SMALL_H + 1 else hs).frame(params[f])
        if abs(size - img.height) > 1 or abs(st - 1.0) > 0.01:
            img = img.resize((round(img.width * size / img.height * st), round(size / st)), Image.LANCZOS)
        x, y = int(cx - img.width / 2), int(fy - img.height)
        if abs(rot) > 0.2:
            img, (ox, oy) = rotate_about(img, rot, (img.width / 2, img.height / 2 if ctr else img.height))
            x, y = x + ox, y + oy
        blend(bgf, np.asarray(img).astype(np.float32), x, y)
        for a, b, (sl, sx_, sy_) in subs:
            if a <= f < b:
                blend(bgf, sl, sx_, sy_)
        enc.stdin.write(bgf.tobytes())
    dec.stdout.close(); dec.wait()
    enc.stdin.close(); enc.wait()
    print(f"{out}  {total / FPS:.1f}秒")
    return out


if __name__ == "__main__":
    want = [int(a) for a in sys.argv[1:]] or [s["no"] for s in SHORTS]
    for sh in SHORTS:
        if sh["no"] in want:
            build(sh)
