# -*- 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'
{head}
', VCSS) def bg(img, size_h, pos_x, pos_y, t_veil): p = "file://" + os.path.abspath(img) return (f'
' f'
' '
絵は生成AI(Gemini)で作成
') def paper(inner, t_in): return f'
{inner}
' 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'
{inner}
' def line(text, t, size=40, weight=700, step=0.05, style=""): return f'
{ink(text, t, step)}
' 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'{txt}') return f'
{"
".join(out)}
' def bigword(text, t, t_out, top, size=300, color="#c4472a", rot=-8): return (f'
{text}
') WHITE, GOLD = "#ffffff", "#ffd84a" # ====================== 3本の中身(T(k, 秒) は k 番目の文の頭から。k=-1 は動画の頭) ====================== def short_uchu(T, S): tp = T(2, 0.0) # 「宇宙は、今日は置いておきます」で、絵を暗くして紙を出す return vpage("ロボットのAI、
考えるのに18.6秒", ( bg("src/bg/宇宙.jpg", 1920, -1500, 0, tp) # 誰の言葉かを先に見せる(2026-11-02 エイイチ「イーロン・マスク氏の話を伝えないと何か分からない」) + f'
イーロン・マスクさんの X の投稿
' + words([("超知能の", WHITE), ("ほぼ100%は", WHITE), ("深宇宙で計算", GOLD)], T(0, 0.3), tp, 570, size=120, gap=0.9) + paper(grp(f'
ハコブくん 考え中
' + f'
0.0{land("秒", T(4, 0.05), "font-size:84px")}
' + line("大きなAIだと、1回考えるのにこのくらい", T(4, 0.6), size=34, weight=500) + line("その間に π0.5 は、動きを370回ほど直せる", T(6, 0.5), size=36, weight=700), tp + 0.1), tp) + bigword("遅っ", T(5, 0.0), T(6, 1.0), 640, size=330) + counter_js("cnt", T(3, 0.2), T(4, -0.05), 18.6) + shake_js([(T(5, 0.25), 0.3)]))) def short_kami(T, S): tp = T(1, 0.0) return vpage("424本、
捨てられない?", ( bg("src/bg/紙の山.jpg", 1920, -1180, 0, tp) + words([("424本", GOLD)], T(0, 5.0), tp, 520, size=230) + words([("捨てられない人?", WHITE)], T(0, 7.6), tp, 800, size=110) + paper(grp(card("mi", ink("LOGは、全部の積み重ね。基幹情報", T(1, 0.4), 0.05), T(1, 0.15), "見立て ・ エイイチ", w=760, anim="wipe") + card("mi", ink("情報がたくさんあるだけでは、言葉や動きは良くならない", T(2, 0.4), 0.04), T(2, 0.15), "見立て ・ エイイチ", w=780, anim="wipe") + '
' + card("fact", "記事の全文", T(3, 0.4), "海馬", w=330) + card("fact", "整理した記事", T(3, 2.3), "大脳", w=330) + '
', tp + 0.1), tp))) def short_intai(T, S): tp = T(2, 0.0) # 本題(何にでも使える大きなAIが…)に入ったら紙を出す return vpage("π0.5 は、
もう引退?", ( bg("src/bg/引退式.jpg", 2300, -1933, 230, tp) + f'
' f'祝・引退
' + bigword("まだ現役", T(1, 0.1), tp, 850, size=180) + paper(grp(card("fact", ink("何にでも使える大きなAIが、追加の練習なしで、ブロックを器に入れた", T(2, 0.4), 0.035), T(2, 0.15), "海外の投資家の書き込み", w=760) + card("hb", "π0.5 は、引退", T(2, 0.3), "ハコブくん", w=460, botsu=T(2, 0.6), fade=T(2, 0.8)) + card("mi", ink("汎用モデルも、使い所次第。組み合わせになる", T(3, 0.4), 0.045), T(3, 0.15), "見立て ・ エイイチ", w=760, anim="wipe"), tp + 0.1, T(4, 0.0)) + grp(f'
{ink("分けるしかない", T(4, 0.3), 0.07)}
' + '
' + f'
遅く考える頭
' + f'
速く直す手
' + '
', T(4, 0.05)), tp) + shake_js([(T(1, 0.4), 0.25)]))) # segs: (本編の文番号, 後ろの間[, 頭を切る秒, 字幕の文]) # big: 大きなハコブくんの出る間(end の文の頭で、左下の大きさへ)。kind: rise(下から飛び出す)/ hop(跳ねて喜ぶ) SHORTS = [ dict(no=1, name="遅っ", fn=short_uchu, lead=0.5, segs=[(18, 0.3), (19, 0.4), (20, 0.5), (21, 3.0), (22, 0.3), (23, 0.5), (25, 1.2)], big=dict(kind="rise", end=1), face=[(-1, 0.0, 6.5, "surprise"), (1, 0.0, 1.6, "trouble"), (3, 0.0, 5.5, "think"), (4, 0.0, 1.0, "smile"), (5, 0.0, 1.4, "trouble")], pose=[(-1, 0.0, 3), (1, 0.0, 2), (2, 0.5, 1), (5, 0.0, 2), (6, 0.0, 3)], ei_rise=None, se=[(-1, 0.0, "se_whoosh", -8), (0, 0.3, "se_pop", -10), (0, 1.2, "se_pop", -10), (0, 2.1, "se_pop", -8), (5, 0.0, "se_pop", -6)] + [(3, 0.2 + k, "se_tick", -16) for k in range(5)]), dict(no=2, name="捨てられない人", fn=short_kami, lead=0.4, segs=[(59, 0.5, 1.15, "エイイチは、記事を全部、全文で残しています。番号は、424番まで来ています。……捨てられない人ですか?"), (60, 0.4), (61, 0.6, 0.0, "情報がたくさんある|だけじゃ、言葉や動きは良くならない。"), (63, 1.2)], big=dict(kind="rise", end=1), face=[(-1, 0.0, 9.5, "doubt"), (1, 0.0, 4.0, "surprise"), (3, 0.0, 4.5, "smile")], pose=[(-1, 0.0, 3), (1, 0.0, 2), (2, 0.0, 1), (3, 0.0, 4)], ei_rise=(0, 7.4), # 「捨てられない人ですか?」で、紙の山の下からエイイチが出てくる se=[(-1, 0.0, "se_whoosh", -8), (0, 5.0, "se_pop", -6), (0, 7.4, "se_rise", -10), (0, 7.6, "se_pop", -8)]), dict(no=3, name="勝手に引退させないで", fn=short_intai, lead=0.6, segs=[(13, 1.0, 1.05, "エイイチの研究室の π0.5 は……引退ですね。お疲れさまでした。"), (14, 0.4), (12, 0.35), (15, 0.5), (36, 1.2)], big=dict(kind="hop", end=1), face=[(-1, 0.0, 7.0, "joy"), (1, 0.0, 2.0, "surprise"), (3, 0.0, 4.4, "trouble")], pose=[(-1, 0.0, 1), (0, 3.0, 3), (1, 0.0, 2), (2, 0.0, 1), (3, 0.0, 2), (4, 0.0, 1)], ei_rise=None, se=[(-1, 0.0, "se_rise", -8), (-1, 0.2, "se_pop", -8), (1, 0.1, "se_pop", -6), (2, 0.0, "se_whoosh", -12)]), ] 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'{_html.escape(ln)}' for ln in ptxt.split("\n")) pg.set_content(f'{svg}') 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 if t >= t_end_big + SHR: return HK_SMALL_H, sx, sy, 0.0 if sh["big"]["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) else: # hop: 跳ねて喜ぶ ph = (t * 1.9) % 1.0 bx, by = 380 + 30 * np.sin(t * 2.0), 1900 - 160 * 4 * ph * (1 - ph) rot = 8 * np.sin(t * 3.8) if t < 0.3: by = 1920 + (by - 1920) * ease(t / 0.3) if t < t_end_big: return HK_BIG_H, bx, by, rot 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) # --- エイイチ(全身・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)) # エイイチは右端に寄せる size, cx, fy, rot = hk_place(t) img = (hb if size > HK_SMALL_H + 1 else hs).frame(params[f]) 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 / 2, 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)