"""マンガのショート（第79話の型）を、話ごとの台本 spec から組む。
使い方: python3 mkshort.py spec_ep81.py [--still 秒,秒] → out_{名前}.mp4
spec に書くもの（spec_ep81.py を見本に）:
  NAME, EP（話の番号）, HEAD（見出し2行）, COMIC（マンガの画像）, SCENES（場面のリスト）
  場面: dict(kind="op", video=..., dur=秒, bubble=["1行目", "2行目"], bubble_until=秒, lines=[...], words=[...])
        dict(kind="panel", crop=(x0,y0,x1,y1), scale=1.55, lines=[...], words=[...], slam=dict(text, at, y), min=秒)
        dict(kind="end", rows=[(文, 大きさ, "ink"|"orange"|"gray", 出る秒)], dur=秒)
  lines: dict(v="voice/ハコブ_01.mp3", who="hk"|"ns", sub="字幕", at=秒（op のときだけ・場面の頭から）, cut=(a,b), db=0, small=True)
         panel では lines を順に読み、場面の長さは声の長さから決まる（min より短くならない）
  words: dict(text, at, x, y, size=150, color="ink"|"orange", rot=0, until=秒)
  se: dict(at=秒（場面の頭から）, f="se/se_don.mp3", db=-6)
"""
import os, sys, math, subprocess, importlib.util
import numpy as np
from scipy import ndimage
from PIL import Image, ImageDraw, ImageFont

W, H, FPS, SR = 1080, 1920, 30, 48000
PAPER = (245, 245, 240); INK = (13, 13, 13); GRAY = (136, 136, 136); ORANGE = (196, 71, 31); BLUE = (120, 200, 255)
COL = {"ink": INK, "orange": ORANGE, "gray": GRAY}
FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Black.ttc"
F = lambda size: ImageFont.truetype(FONT, size, index=0)
VX, VY, VW, VH = 0, 466, 1080, 974
SUB_Y = 1488
OP_CROP = (0, 172, 848, 937)
HERE = os.path.dirname(os.path.abspath(__file__))
POP = os.path.join(HERE, "base/se_pop.wav")

spec_path = sys.argv[1]
sp = importlib.util.spec_from_file_location("spec", spec_path); S = importlib.util.module_from_spec(sp); sp.loader.exec_module(S)
os.chdir(os.path.dirname(os.path.abspath(spec_path)))

def load_audio(path, af=None):
    r = subprocess.run(["ffmpeg", "-v", "error", "-i", path] + (["-af", af] if af else []) + ["-f", "f32le", "-ac", "1", "-ar", str(SR), "-"], capture_output=True)
    return np.frombuffer(r.stdout, np.float32).copy()
def voice(l):
    a = load_audio(l["v"], "highpass=f=450,lowpass=f=3200" if l.get("phone") else None)   # phone=True: 電話の向こうの声
    if l.get("cut"):
        a0, a1 = l["cut"]; a = np.concatenate([a[:int(a0 * SR)], a[int(a1 * SR):]])
    e = np.abs(a); idx = np.where(e > e.max() * 0.06)[0]
    a = a[max(0, idx[0] - int(0.02 * SR)):idx[-1] + int(0.08 * SR)]     # 前後の無音を落とす
    return a


def add_sub(st, length, l, end=None):
    """字幕を「||」でページに分け、声の長さを字数で割り振って順に出す（2026-10-24）"""
    pages = l["sub"].split("||")
    n = [max(1, len(p.replace("|", "").replace("【", "").replace("】", ""))) for p in pages]
    tot, acc = sum(n), 0
    for k, (pg, c) in enumerate(zip(pages, n)):
        a = st + length * acc / tot; acc += c
        last = k == len(pages) - 1
        SUBS.append((a, (end if last else st + length * acc / tot), pg, l["who"], l.get("small")))

# ---------------- 時刻を決める ----------------
T = 0.0
EVENTS = []          # (開始, 終了, 場面)
AUDIO = []           # (時刻, 波形, dB)
SUBS = []            # (開始, 終了, 字幕, 誰, 小さい)
for sc in S.SCENES:
    t0 = T
    if sc["kind"] == "op":
        dur = sc["dur"]
        for l in sc.get("lines", []):
            a = voice(l); AUDIO.append((t0 + l["at"], a, l.get("db", 0)))
            add_sub(t0 + l["at"], len(a) / SR, l, min(t0 + l["at"] + len(a) / SR + 0.5, t0 + dur))
    elif sc["kind"] == "panel":
        cur = t0 + 0.15
        for l in sc.get("lines", []):
            a = voice(l)
            st = cur + l.get("gap", 0)
            AUDIO.append((st, a, l.get("db", 0)))
            if l.get("with"):                      # 同時に言う人
                for w2 in l["with"]:
                    a2 = voice(dict(v=w2)); AUDIO.append((st + 0.025, a2, l.get("db", 0) - 2))
            add_sub(st, len(a) / SR, l, None)
            cur = st + len(a) / SR + 0.3
        dur = max(sc.get("min", 1.8), cur - t0 + 0.35)
    else:
        dur = sc.get("dur", 4.2)
    for se in sc.get("se", []):
        AUDIO.append((t0 + se["at"], load_audio(se["f"]), se.get("db", -6)))
    if sc["kind"] in ("panel", "end"):
        AUDIO.append((t0, load_audio(POP), -13))
    if sc["kind"] == "end":
        for txt, sz, col, d0 in sc["rows"]:
            if d0 > 0 and sz >= 90: AUDIO.append((t0 + d0, load_audio(POP), -12)); break
    EVENTS.append((t0, t0 + dur, sc)); T += dur
TOTAL = T
# 字幕の終わり: 次の字幕か場面の終わりまで
SUBS.sort(key=lambda s: s[0])
fixed = []
for i, (a, b, txt, who, sm) in enumerate(SUBS):
    end_scene = next(e1 for e0, e1, _ in EVENTS if e0 <= a < e1)
    nxt = SUBS[i + 1][0] if i + 1 < len(SUBS) else TOTAL
    fixed.append((a, min(end_scene, nxt) if b is None else min(b, nxt), txt, who, sm))
SUBS = fixed

# ---------------- 部品 ----------------
def text_img(txt, size, fill=INK, stroke=0, stroke_fill=(255, 255, 255), hl=ORANGE):
    f = F(size); parts, cur, on = [], "", False
    for ch in txt:
        if ch in "【】":
            if cur: parts.append((cur, on)); cur = ""
            on = ch == "【"; continue
        cur += ch
    if cur: parts.append((cur, on))
    plain = "".join(p for p, _ in parts); bb = f.getbbox(plain, stroke_width=stroke)
    im = Image.new("RGBA", (bb[2] - bb[0] + 2 * stroke + 8, size + 2 * stroke + int(size * 0.35)), (0, 0, 0, 0))
    d = ImageDraw.Draw(im); x = stroke + 4 - bb[0]
    for p, on in parts:
        d.text((x, stroke), p, font=f, fill=(hl if on else fill), stroke_width=stroke, stroke_fill=stroke_fill); x += f.getlength(p)
    if stroke:                                  # 「。」や「゜」の穴（透けて白く見える）をふち色で埋める
        a = np.array(im)
        solid = a[..., 3] > 250
        holes = ndimage.binary_dilation(ndimage.binary_fill_holes(solid) & ~solid, iterations=2) & ~solid
        al = a[..., 3:4].astype(np.float32) / 255           # 穴とそのふちの半透明を、ふち色の上に重ねる
        bgc = np.array(stroke_fill[:3], np.float32)
        a[holes, :3] = (a[holes, :3] * al[holes] + bgc * (1 - al[holes])).astype(np.uint8)
        a[holes, 3] = 255
        im = Image.fromarray(a)
    return im
def paste_c(base, im, cx, cy, scale=1.0, rot=0.0):
    if scale != 1.0: im = im.resize((max(1, int(im.width * scale)), max(1, int(im.height * scale))), Image.LANCZOS)
    if rot: im = im.rotate(rot, resample=Image.BICUBIC, expand=True)
    base.alpha_composite(im, (int(cx - im.width / 2), int(cy - im.height / 2)))
def ease(u): u = min(max(u, 0), 1); return u * u * (3 - 2 * u)
def pop(t, t0, dur=0.18):
    u = (t - t0) / dur
    if u <= 0: return 0
    if u >= 1: return 1 + 0.08 * math.exp(-(t - t0 - dur) * 12) * math.cos((t - t0 - dur) * 30)
    return 1.15 * math.sin(u * math.pi / 2)
def slam(t, t0, dur=0.14, big=2.6):
    u = (t - t0) / dur
    if u <= 0: return None
    if u >= 1: return 1 + 0.05 * math.exp(-(t - t0 - dur) * 10) * math.cos((t - t0 - dur) * 35)
    return big + (1 - big) * ease(u)
def shake(t, t0, amp, dec=9, hz=23):
    if t < t0: return 0
    v = t - t0; return amp * math.exp(-v * dec) * math.sin(2 * math.pi * hz * v)

logo = Image.open(os.path.join(HERE, "logo_dark.png")).convert("RGBA")
logo = logo.resize((int(logo.width * 54 / logo.height), 54), Image.LANCZOS)
BASE = Image.new("RGBA", (W, H), PAPER + (255,)); BASE.alpha_composite(logo, (60, 175))
for k, line in enumerate(S.HEAD):
    BASE.alpha_composite(text_img(line, 86), (52, 228 + k * 96))
BASE.alpha_composite(text_img(f"4コママンガ『ハコブくん』第{S.EP}話より（つくり話・絵は生成AIで作成）", 24, fill=GRAY), (60, VY + VH + 8))
comic = Image.open(S.COMIC).convert("RGB")
_pc = {}
_fr = {}
sys.path.insert(0, HERE)
from bubblefit import framed                     # 吹き出しを切らない切り抜き（2026-10-24）
def framed_panel(sc):
    """コマの画像（RGBA）。sc["fit"]=False なら今までどおり crop のまま。sc["bub"]=[(x, y)] で拾えない吹き出しを足す"""
    k = (sc["crop"], tuple(sc.get("bub", ())), tuple(sc.get("skip", ())), sc.get("fit", True))
    if k not in _fr:
        if sc.get("fit", True):
            _fr[k] = framed(comic, sc["crop"], sc.get("bub", ()), skip=sc.get("skip", ()))[0]
        else:
            c = comic.crop(sc["crop"]).convert("RGBA")
            ImageDraw.Draw(c).rectangle([0, 0, c.width - 1, c.height - 1], outline=INK, width=6); _fr[k] = c
    return _fr[k]
def panel(sc, scale):
    k = (sc["crop"], tuple(sc.get("bub", ())), tuple(sc.get("skip", ())), round(scale, 3))
    if k not in _pc:
        c = framed_panel(sc); _pc[k] = c.resize((int(c.width * scale), int(c.height * scale)), Image.LANCZOS)
    return _pc[k]
_vid = {}
def vframe(path, t):
    if path not in _vid:
        r = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True)
        _vid[path] = np.frombuffer(r.stdout, np.uint8).reshape(-1, 1056, 848, 3)
    v = _vid[path]; i = min(max(int(round(t * FPS)), 0), len(v) - 1)
    return Image.fromarray(v[i]).crop(OP_CROP).resize((VW, VH), Image.LANCZOS).convert("RGBA")
def bubble(lines, size=60, side="L"):
    f = F(size); tw = max(f.getlength(l.replace("【", "").replace("】", "")) for l in lines); th = len(lines) * size * 1.25
    pw, ph = int(tw + 70), int(th + 50)
    im = Image.new("RGBA", (pw + 60, ph + 10), (0, 0, 0, 0)); d = ImageDraw.Draw(im)
    ox = 60 if side == "L" else 0                  # 吹き出しの体の左端（しっぽは反対側に）
    d.rounded_rectangle([ox, 4, ox + pw, 4 + ph], radius=46, fill=(255, 255, 255, 255), outline=INK, width=6)
    if side == "L":
        d.polygon([(0, ph * 0.72), (66, ph * 0.45), (66, ph * 0.78)], fill=(255, 255, 255, 255))
        d.line([(64, ph * 0.45), (0, ph * 0.72), (64, ph * 0.80)], fill=INK, width=6, joint="curve")
    else:
        X = pw + 60
        d.polygon([(X, ph * 0.72), (pw - 6, ph * 0.45), (pw - 6, ph * 0.78)], fill=(255, 255, 255, 255))
        d.line([(pw - 4, ph * 0.45), (X, ph * 0.72), (pw - 4, ph * 0.80)], fill=INK, width=6, joint="curve")
    y = 29
    for l in lines:
        x = ox + 35; on = False
        for ch in l:
            if ch in "【】": on = ch == "【"; continue
            d.text((x, y), ch, font=f, fill=(ORANGE if on else INK)); x += f.getlength(ch)
        y += size * 1.25
    return im
_wc = {}
def word(w):
    k = (w["text"], w.get("size", 150), w.get("color", "ink"))
    if k not in _wc: _wc[k] = text_img(w["text"], w.get("size", 150), fill=COL[w.get("color", "ink")], stroke=12, stroke_fill=(255, 255, 255))
    return _wc[k]
import functools
@functools.lru_cache(maxsize=None)
def subtitle(txt, who, small):
    if small:
        return text_img(txt, 30, fill=BLUE if who == "hk" else (255, 255, 255), stroke=5, stroke_fill=INK)
    rows = txt.split("|")                 # 「|」で2行に分ける（1行は13字まで）
    plain = lambda r: len(r.replace("【", "").replace("】", ""))
    if len(rows) == 1 and plain(txt) > 13:     # 長いときは真ん中に近い「、」「。」で自動で折る
        cands = [i + 1 for i, ch in enumerate(txt[:-1]) if ch in "、。！？"]
        if cands:
            k = min(cands, key=lambda i: abs(plain(txt[:i]) - plain(txt) / 2)); rows = [txt[:k], txt[k:]]
    ims = [text_img(r, 66, fill=BLUE if who == "hk" else (255, 255, 255), stroke=11, stroke_fill=INK, hl=(255, 140, 90)) for r in rows]
    if len(ims) == 1: return ims[0]
    out = Image.new("RGBA", (max(i.width for i in ims), sum(i.height - 14 for i in ims) + 14), (0, 0, 0, 0))
    y = 0
    for i in ims: out.alpha_composite(i, ((out.width - i.width) // 2, y)); y += i.height - 14
    return out

def frame(t):
    img = BASE.copy(); vis = Image.new("RGBA", (VW, VH), PAPER + (255,)); cam = (0, 0)
    t0, t1, sc = next(((a, b, s) for a, b, s in EVENTS if a <= t < b), EVENTS[-1])
    lt = t - t0; u = lt / (t1 - t0)
    if sc["kind"] == "op":
        vis = vframe(sc["video"], lt + sc.get("src_t0", 0))
        b0 = sc.get("bubble_from", 0.0)
        if sc.get("bubble") and b0 <= lt < sc.get("bubble_until", 99):
            if "_bub" not in sc: sc["_bub"] = bubble(sc["bubble"])
            k = pop(lt, b0, 0.2)
            if k: paste_c(vis, sc["_bub"], 30 + sc["_bub"].width * k / 2, 105, scale=k)
        for bb in sc.get("bubbles", []):          # 左右から別々の人がしゃべる（2026-10-20）
            if bb["frm"] <= lt < bb["until"]:
                if "_img" not in bb: bb["_img"] = bubble(bb["lines"], side=bb.get("side", "L"))
                k = pop(lt, bb["frm"], 0.2)
                if k:
                    w = bb["_img"].width
                    cx = (30 + w * k / 2) if bb.get("side", "L") == "L" else (VW - 30 - w * k / 2)
                    paste_c(vis, bb["_img"], cx, bb.get("y", 105), scale=k)
    elif sc["kind"] == "panel":
        fw, fh = framed_panel(sc).size
        s0 = sc.get("scale") or min(1040 / fw, 930 / fh)
        s = s0 * (0.96 + 0.04 * ease(u))
        paste_c(vis, panel(sc, s), VW / 2, VH / 2 + sc.get("dy", 0))
        if sc.get("slam"):
            sl = sc["slam"]
            if "_img" not in sl: sl["_img"] = text_img(sl["text"], sl.get("size", 160), fill=COL[sl.get("color", "ink")], stroke=14, stroke_fill=(255, 255, 255))
            k = slam(lt, sl["at"])
            if k: paste_c(vis, sl["_img"], VW / 2, sl.get("y", 200), scale=k, rot=sl.get("rot", -4))
            cam = (shake(lt, sl["at"] + 0.14, 14), shake(lt, sl["at"] + 0.14, 9, 9, 29))
    else:
        y = 190
        for txt, sz, col, d0 in sc["rows"]:
            k = pop(lt, d0, 0.16)
            if k: paste_c(vis, text_img(txt, sz, fill=COL[col]), VW / 2, y + sz / 2, scale=k)
            y += sz * 1.45 + (24 if sz >= 70 else 0)
    for w in sc.get("words", []):
        if w["at"] <= lt < w.get("until", 99):
            paste_c(vis, word(w), w["x"], w["y"], scale=pop(lt, w["at"], 0.12) or 0.01, rot=w.get("rot", 0))
    if cam != (0, 0):
        vis = vis.transform(vis.size, Image.AFFINE, (1, 0, -cam[0], 0, 1, -cam[1]), resample=Image.BILINEAR, fillcolor=PAPER + (255,))
    img.alpha_composite(vis, (VX, VY))
    for a, b, txt, who, sm in SUBS:
        if a <= t < b:
            paste_c(img, subtitle(txt, who, sm), W / 2, SUB_Y + 60 + max(0, (subtitle(txt, who, sm).height - 105) // 2), scale=min(pop(t, a, 0.12) or 0.01, 1.06))
    return img.convert("RGB")

if __name__ == "__main__":
    if "--still" in sys.argv:
        for s in sys.argv[sys.argv.index("--still") + 1].split(","):
            frame(float(s)).save(f"still_{S.NAME}_{s}.png")
        print("場面:", [(round(a, 2), round(b, 2), s["kind"]) for a, b, s in EVENTS]); sys.exit(0)
    n = int(TOTAL * FPS)
    p = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS),
                          "-i", "-", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "20", f"video_{S.NAME}.mp4"], stdin=subprocess.PIPE)
    for i in range(n): p.stdin.write(np.asarray(frame(i / FPS)).tobytes())
    p.stdin.close(); p.wait()
    out = np.zeros(int((TOTAL + 1) * SR), np.float32)
    for t, a, db in AUDIO:
        i = int(t * SR); m = min(len(a), len(out) - i); out[i:i + m] += a[:m] * (10 ** (db / 20))
    pk = np.abs(out).max() or 1
    if pk > 0.95: out *= 0.95 / pk
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(SR), "-ac", "1", "-i", "-", f"mix_{S.NAME}.wav"], input=out.tobytes())
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", f"video_{S.NAME}.mp4", "-i", f"mix_{S.NAME}.wav", "-c:v", "copy", "-c:a", "aac",
                    "-b:a", "192k", "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-shortest", f"out_{S.NAME}.mp4"])
    print("できあがり:", f"out_{S.NAME}.mp4", round(TOTAL, 1), "秒")
