# -*- coding: utf-8 -*-
"""考察回「壁打ちの型」の縦長ショート（1080×1920）。2本目「現場で扱えるか」用（中身の3本だけ書き替えた。部品は1本目の版2のまま）。\n1本目の版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>'
            '<div style="position:absolute;left:0;right:0;top:0;height:560px;background:linear-gradient(rgba(20,19,17,.78),rgba(20,19,17,.45) 70%,rgba(20,19,17,0))"></div>'   # 白いマンガの絵でも見出しが読めるように（2本目で足した）
            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 考察回2本目「現場で扱えるか」（LOG389）の3本。書き写した元: 頭脳は脳のように分かれる/kabe_short.py（版2）
def short_six(T, S):
    tp = T(1, 0.0)   # 「でも、1時間に600回働くと」で、絵を暗くして紙を出す
    return vpage("成功率99%の<br>ロボット", (
        bg("src/bg/1時間に6回.jpg", 1920, -1180, 0, tp)
        + words([("成功率", WHITE), ("99%", GOLD), ("かなり優秀", WHITE)], T(0, 0.4), tp, 560, size=130, gap=1.2)
        + paper(grp(line("1時間に600回働くと", T(1, 0.6), size=44, weight=700)
                    + f'<div class="num" style="font-size:230px;line-height:1"><span id="c6">0</span>{land("回", T(1, 3.4), "font-size:96px")}</div>'
                    + line("人が駆けつける", T(1, 3.6), size=44, weight=700)
                    + card("fact", ink("10分に1回、呼ばれるロボット", T(2, 0.6), 0.05), T(2, 0.3), "店の人からすると", w=760), tp + 0.1), tp)
        + bigword("優秀？", T(3, 0.1), T(4, S(4) + 0.6), 640, size=250)
        + counter_js("c6", T(1, 2.4), T(1, 3.4), 6, dec=0) + shake_js([(T(3, 0.4), 0.3)])))


def short_oku(T, S):
    tp = T(1, 0.0)   # 「だから人に任せたんだよ」で紙を出す
    col = lambda a, b, t: (f'<div class="card k-fact" style="width:370px;opacity:0;animation:pop .45s ease-out {t:.3f}s forwards">'
                           f'<small>{a}</small><span class="serif" style="font-size:66px;font-weight:900;line-height:1.3">{b}</span></div>')
    return vpage("料理も、<br>僕が置きます", (
        bg("src/bg/最後の動作.jpg", 1920, -900, 0, tp)
        + words([("最後まで", WHITE), ("責任を持って", GOLD)], T(0, 2.6), tp, 560, size=120, gap=0.8)
        + bigword("ドテッ", T(0, S(0) + 0.05), tp, 820, size=200, rot=-10)
        + paper(grp(card("mi", ink("だから、人に任せた", T(1, 0.3), 0.06), T(1, 0.1), "エイイチ", w=600, anim="wipe")
                    + line("一番難しい所を、ロボットにやらせていない", T(2, 0.3), size=36, weight=700), tp + 0.1, T(3, 0.0))
                + grp(f'<div class="row2" style="gap:24px">{col("料理をテーブルに置く", "お客さん", T(3, 0.3))}{col("トレーに載せる", "店員さん", T(3, 2.4))}</div>'
                      + f'<div class="serif" style="font-size:46px;font-weight:700;margin-top:10px">{ink("頑張らない所を決めて、", T(4, 0.3), 0.06)}<br>{land("広がった", T(4, 2.2), "font-size:72px;font-weight:900")}</div>', T(3, 0.05)), tp)
        + shake_js([(T(0, S(0) + 0.35), 0.3)])))


def short_tobira(T, S):
    tp = T(2, 0.0)   # 「扉を通らない手があります」で紙を出す
    lab = lambda txt, t: f'<div style="{SANS}font-size:28px;font-weight:700;color:#5a564f;height:44px;text-align:center">{ink(txt, t, 0.05)}</div>'
    seg = lambda txt, bgc, fg, t: (f'<div class="seg" style="width:250px;background:{bgc};color:{fg};font-size:34px;clip-path:inset(0 100% 0 0);animation:wipe .45s ease-out {t:.3f}s forwards">{txt}</div>')
    return vpage("ロボットは、<br>扉を開けられない", (
        bg("src/bg/扉.jpg", 1920, -1900, 0, tp)
        + words([("夜から", WHITE), ("朝まで", GOLD), ("開けて……", WHITE)], T(0, 1.2), T(1, 0.0), 560, size=120, gap=1.6)
        + words([("扉は", WHITE), ("体当たりで", GOLD)], T(1, 0.6), tp, 600, size=130, gap=0.6)
        + bigword("ボツ", T(1, S(1) + 0.3), tp, 1100, size=200, rot=-12)
        + gon_words(tp)
        + paper(grp(line("扉を通らない手", T(2, 0.2), size=46, weight=900)
                    + '<div style="display:flex;width:750px">'
                    + f'<div style="width:250px">{lab("洗い場側", T(2, 1.4))}</div><div style="width:250px">{lab("両側から取れる棚", T(2, 2.2))}</div><div style="width:250px">{lab("客席側", T(2, 1.4))}</div></div>'
                    + '<div style="display:flex;width:750px">'
                    + seg("調理場", "#cfc8ba", "#22201c", T(2, 1.6)) + seg("棚", "#22201c", "#f5f3ee", T(2, 3.0)) + seg("ロボット", "#ece9e2", "#22201c", T(2, 5.0))
                    + '</div>'
                    + line("ロボットは、棚にトレーを置くだけ", T(2, 5.6), size=36, weight=700), tp + 0.1), tp)
        + shake_js([(T(1, S(1) + 0.6), 0.3)])))


# segs: (本編の文番号, 後ろの間[, 頭を切る秒, 字幕の文])
# big: 大きなハコブくんの出る間（end の文の頭で、左下の大きさへ）。kind: rise（下から飛び出す）/ hop（跳ねて喜ぶ）
SHORTS = [
    dict(no=1, name="成功率99パーセント", fn=short_six, lead=0.5,
         segs=[(0, 0.35), (1, 0.5, 0.0, "でも、1時間に600回働くと、｜1時間に6回、｜人が駆けつける｜ことになります。"), (2, 0.6), (3, 0.5), (4, 1.2, 0.0, "……僕も、｜自信が｜なくなってきました。")],
         big=dict(kind="dash", end=1),
         face=[(-1, 0.0, 5.5, "joy"), (1, 0.0, 6.0, "base"), (2, 0.0, 4.0, "surprise"), (3, 0.0, 2.0, "doubt"), (4, 0.0, 3.0, "trouble")],
         pose=[(-1, 0.0, 2), (1, 0.0, 1), (2, 0.0, 3), (3, 0.0, 3), (4, 0.6, 4)], ei_rise=None,
         se=[(-1, 0.0, "se_whoosh", -8), (0, 0.4, "se_pop", -10), (0, 1.6, "se_pop", -8), (0, 2.8, "se_pop", -10), (1, 3.4, "se_pop", -8), (3, 0.1, "se_pop", -6)]
            + [(1, 2.4 + 0.18 * k, "se_tick", -16) for k in range(6)]),
    dict(no=2, name="料理も僕が置きます", fn=short_oku, lead=0.4,
         segs=[(27, 0.6), (28, 0.5), (25, 0.35, 0.0, "しかも、一番難しい所を、｜ロボットに｜やらせていません。"), (26, 0.6), (29, 1.2, 0.95, "配膳ロボットは、頑張らない所を決めて、広がりました。")],
         big=dict(kind="fall", end=2, fall=(0, 4.9)),   # 「だから人に任せたんだよ」が終わるまで倒れたまま
         face=[(-1, 0.0, 4.9, "joy"), (0, 4.9, 3.8, "x"), (1, 0.0, 2.2, "trouble"), (4, 0.0, 3.5, "smile")],
         pose=[(-1, 0.0, 1), (0, 5.1, 3), (1, 0.0, 2), (2, 0.0, 1), (3, 0.0, 2), (4, 0.0, 4)], ei_rise=None,
         se=[(-1, 0.0, "se_rise", -8), (0, 2.6, "se_pop", -10), (0, 3.4, "se_pop", -8), (0, 4.9, "se_whoosh", -10), (0, 5.2, "se_pop", -6), (1, 0.0, "se_whoosh", -12)]),
    dict(no=3, name="扉は体当たり", fn=short_tobira, lead=0.5,
         segs=[(63, 0.4, 0.0, "ロボットがお店のフロントから｜バックヤードに行くには、｜扉を開けられない｜といけない。｜ここが今、目に見える問題かな。"), (64, 0.9), (66, 1.2, 0.66, "扉を通らない手があります。両側から取り出せる棚を客席側に置いて、ロボットはそこにトレーを置くだけにする。")],
         big=dict(kind="wall", end=2),
         face=[(-1, 0.0, 9.0, "x"), (1, 0.0, 3.4, "joy"), (1, 3.5, 1.2, "x"), (2, 0.0, 4.0, "base")],
         pose=[(-1, 0.0, 1), (0, 0.0, 2), (1, 0.0, 3), (1, 3.4, 4), (2, 0.0, 2)], ei_rise=None,
         se=[(-1, 0.0, "se_whoosh", -8), (0, 1.2, "se_pop", -10), (0, 2.8, "se_pop", -10), (0, 4.4, "se_pop", -8), (1, 0.6, "se_pop", -10), (1, 1.2, "se_pop", -8), (1, 3.6, "se_pop", -6), (2, 0.0, "se_whoosh", -12)]),
]


# ---- 2本目で足した大きなハコブくんの動き（2026-11-02 エイイチの指摘）----
# dash: 左右に何度も素早く行き来する（ショート1）/ fall: ボケのあと前のめりにコケる（ショート2）/ wall: 左の端の壁に何度もぶつかる（ショート3）
WALL_T0, WALL_PERIOD = 0.5, 0.95     # wall: 最初にぶつかるまでの準備と、1回の往復の秒


def wall_impacts(t_end):
    """wall の動きで、壁にぶつかる時刻の一覧"""
    out, t = [], WALL_T0 + WALL_PERIOD * 0.55
    while t < t_end - 0.2:
        out.append(round(t, 3)); t += WALL_PERIOD
    return out


def gon_words(t_end):
    """壁にぶつかるたびに「ゴン」を出す"""
    return "".join(
        f'<div class="big" style="left:40px;right:auto;top:{900 + (k % 3) * 70}px;font-size:120px;color:#fff;-webkit-text-stroke:8px #141311;paint-order:stroke fill;'
        f'text-shadow:0 10px 0 #c4472a;transform:rotate({-14 + (k % 2) * 8}deg);z-index:6"><span style="animation:boom .2s cubic-bezier(.2,1.8,.4,1) {t:.3f}s forwards, hide .15s linear {t + 0.45:.3f}s forwards">ゴン</span></div>'
        for k, t in enumerate(wall_impacts(t_end)))


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_place(t):
        """→ (高さ, 足もとの中心 x, 足もと y, 角度, 回す中心の横の割合)"""
        sx, sy = HK_X + hs.w / 2, FOOT
        kind = sh["big"]["kind"]
        big_h = 720 if kind == "fall" else HK_BIG_H
        bw = hb.w * big_h / HK_BIG_H
        pv = 0.5
        if t >= t_end_big + SHR:
            return HK_SMALL_H, sx, sy, 0.0, pv
        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)
            bx, by = 540 + 40 * np.sin(t * 1.1), 1990 + (1900 - 1990) * k + 14 * np.sin(t * 2.2)
            rot = 6 * np.sin(t * 1.3)
        elif kind == "dash":   # 左右に何度も素早く行き来する。向きを変える所で少し止まって傾きが戻る
            per = 0.5
            ph = (t % per) / per
            leg = int(t // per) % 2
            u = ease(min(1, ph / 0.8))
            xl, xr = bw / 2 - 150, 1080 - bw / 2 + 110   # 左右の端から少しはみ出すまで走る（版1は幅が約240pxで動いて見えなかった）
            x0, x1 = (xl, xr) if leg == 0 else (xr, xl)
            bx = x0 + (x1 - x0) * u
            by = 1900 - 26 * abs(np.sin(ph * np.pi * 3))
            rot = (9 if leg == 0 else -9) * (1 - ease((ph - 0.8) / 0.2)) if ph > 0.8 else (9 if leg == 0 else -9) * min(1, ph / 0.12)
            if t < 0.25:
                by = 1990 + (by - 1990) * ease(t / 0.25)
        elif kind == "fall":   # 跳ねて喜んだあと、前のめりにコケる（回す中心は前の足もと）
            tf = Tf(*sh["big"]["fall"])
            bx, by = 260, 1900
            if t < tf:
                ph = (t * 1.9) % 1.0
                by = 1900 - 120 * 4 * ph * (1 - ph)
                rot = 6 * np.sin(t * 3.8)
                if t < 0.3:
                    by = 1920 + (by - 1920) * ease(t / 0.3)
            else:
                u = t - tf
                pv = 0.95
                rot = 86 * ease(u / 0.32) if u < 0.32 else 86 + 5 * np.exp(-(u - 0.32) * 8) * np.cos((u - 0.32) * 28)
        else:   # wall: 左の端の壁に、何度もぶつかる
            xw, xf = bw / 2 - 6, bw / 2 + 300
            if t < WALL_T0:
                bx, rot, by = xf, 0.0, 1990 + (1900 - 1990) * ease(t / WALL_T0)
            else:
                ph = ((t - WALL_T0) % WALL_PERIOD) / WALL_PERIOD
                by = 1900
                if ph < 0.55:   # 壁へ向かって速くなる
                    u = (ph / 0.55) ** 2
                    bx, rot = xf + (xw - xf) * u, -7 * u
                else:           # ぶつかって跳ね返る（はじめの一瞬は体が反る）
                    v = (ph - 0.55) / 0.45
                    bx = xw + (xf - xw) * ease(v)
                    rot = 14 * np.exp(-v * 5) * np.cos(v * 18)
                    by = 1900 - 40 * np.sin(min(1, v * 2) * np.pi)
        if t < t_end_big:
            return big_h, bx, by, rot, pv
        u = ease((t - t_end_big) / SHR)
        return big_h + (HK_SMALL_H - big_h) * u, bx + (sx - bx) * u, by + (sy - by) * u, rot * (1 - u), pv

    # --- エイイチ（全身・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
    se_list = list(sh["se"])
    if sh["big"]["kind"] == "dash":
        se_list += [(-1, 0.5 * k + 0.05, "se_whoosh", -16) for k in range(int(t_end_big / 0.5))]
    if sh["big"]["kind"] == "wall":
        se_list += [(-1, ti, "se_pop", -6) for ti in wall_impacts(t_end_big)]
    for k, off, name, lv in se_list:
        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']}_版1.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))   # エイイチは右端に寄せる
        size, cx, fy, rot, pv = hk_place(t)
        img = (hb if size > HK_SMALL_H + 1 else hs).frame(params[f])
        if sh["big"]["kind"] == "wall" and t < t_end_big:
            # 壁にぶつかっている間は、壁（左）を向く。小さくなる所で、エイイチに向き合う右向きに戻る（2026-11-02 エイイチの指摘）
            img = img.transpose(Image.FLIP_LEFT_RIGHT)
            rot, pv = -rot, 1 - pv
        if abs(size - img.height) > 1:
            img = img.resize((round(img.width * size / img.height), round(size)), 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 * pv, 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)
