# -*- coding: utf-8 -*-
"""ショート「AIあるある」の共通の書き出し(1080×1920・2026-11-02「確認いらん」で作り、2本目から共通にした)。
各回の作業フォルダに narration.py と spec.py(NAME・NO・HEAD・scene(T,S)・FACE・POSE・LEAN・SE・ENDING・LEAD)を置き、そこで
P2M_BASE=<_制作ベース> python3 ../AIあるある_共通/aiaru_kit.py [--still 秒,…]
書き写した元: 動画/現場で扱えるか/kabe_short.py(考察回のショート。2人とも全身・左下ハコブくん・右下エイイチ)
この回で足したもの:
- 確認の窓(回数つき)が、ハコブくんが確認するたびに積み重なる
- エイイチの心の声を、縦書きの黒い札で叩きつける(参考: AI と酔っぱらいの縦長動画のツッコミ)。言葉はエイイチが決めた
- ハコブくんは確認するたびに大きく・前に乗り出す
- 最後にエイイチがコケる(全身の絵を足もとを軸に倒す)
- 字幕の色: ハコブくんは水色、エイイチは白(マンガのショートと同じ決まり)
使い方: P2M_BASE=<_制作ベース> python3 kakunin_short.py [--still 秒,秒,...]
"""
import os, sys, json, wave, subprocess, html as _html
import numpy as np
from PIL import Image, ImageFilter, ImageOps
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.getcwd())
import kabe_kit as K
from kabe_kit import ease, blend, rotate_about, hk, FPS, SR, shake_js
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
SUB_MID, SUB_CX, SUB_MAXC = 1150, 540, 11
EI_SCALE = 4.5
HK_SMALL_H, HK_X, HK_BIG_H = 515, 10, 900
EI_POSES = {1: "立つ", 2: "腕組み", 3: "驚く", 4: "笑う"}
HK_BLUE = "rgb(120,200,255)"
VCSS = """
html, body { width:1080px; height:1920px; }
.f { width:1080px; height:1920px; background: radial-gradient(ellipse at 50% 42%, #3a362f 0%, #22201c 55%, #141311 100%); }
.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; }
.dlg { position:absolute; background:#f5f3ee; border-radius:22px; box-shadow:0 26px 50px rgba(0,0,0,.55); overflow:hidden; opacity:0; font-family:'Noto Sans CJK JP',sans-serif; color:#22201c; }
.dlg .bar { background:#22201c; color:#f5f3ee; font-size:32px; font-weight:700; padding:14px 28px; display:flex; align-items:center; gap:14px; }
.dlg .bar i { font-style:normal; width:44px; height:44px; border-radius:50%; background:#ffd84a; color:#22201c; display:flex; align-items:center; justify-content:center; font-weight:900; }
.dlg .body { font-size:50px; font-weight:800; line-height:1.35; text-align:center; padding:34px 30px 22px; }
.dlg .btns { display:flex; gap:24px; justify-content:center; padding:0 30px 30px; }
.dlg .btns b { font-size:36px; padding:12px 0; width:220px; text-align:center; border-radius:14px; border:4px solid #22201c; }
.dlg .btns b.y { background:#22201c; color:#f5f3ee; }
.dlg.red .bar { background:#c4472a; }
@keyframes dlgIn { 0% { opacity:0; transform:var(--r) scale(1.5); } 70% { opacity:1; transform:var(--r) scale(.96); } 100% { opacity:1; transform:var(--r) scale(1); } }
.tk { position:absolute; writing-mode:vertical-rl; background:#0d0d0d; color:#fff; border:6px solid #fff; font-family:'Noto Sans CJK JP',sans-serif;
font-weight:900; line-height:1.12; padding:20px 12px; box-shadow:0 14px 30px rgba(0,0,0,.6); opacity:0; z-index:8; text-orientation:upright; }
.dlg .body.eng { font-family:'Noto Sans','Noto Sans CJK JP',sans-serif; font-size:30px; font-weight:500; line-height:1.4; text-align:left; padding:24px 34px 16px; }
.dlg .note { position:absolute; right:22px; top:12px; font-size:24px; color:#bdb7ab; font-weight:700; }
.chk { font-size:44px; font-weight:900; color:#f5f3ee; text-shadow:0 4px 0 #141311; }
@keyframes dim { to { filter:brightness(.42); } }
@keyframes tkIn { 0% { opacity:0; transform:rotate(4deg) scale(2.2); } 70% { opacity:1; transform:rotate(4deg) scale(.95); } 100% { opacity:1; transform:rotate(4deg) scale(1); } }
"""
def vpage(head, body, bg_html=""):
return K.page(bg_html + body + f'

{head}
', VCSS)
def bg_layer(T):
"""背景の写真(Gemini で作成)。最初は明るく見せ、話の中身が出る所で暗くして、窓と字幕を読みやすくする"""
if not BG:
return ""
p = "file://" + os.path.abspath(BG["file"])
t = T(*BG.get("at", (0, 0.0)))
v = BG.get("veil", 0.62)
return (f''
''
f''
f''
'背景は生成AI(Gemini)で作成
')
import importlib.util as _ilu
def _here(name):
"""作業フォルダ(いまのフォルダ)の name.py を読む(_制作ベース に同じ名前があるため、場所を決めて読む)"""
sp = _ilu.spec_from_file_location(name + "_here", os.path.join(os.getcwd(), name + ".py"))
m = _ilu.module_from_spec(sp); sp.loader.exec_module(m)
return m
SP = _here("spec")
NAME, LEAD, FACE, POSE, LEAN, SE = SP.NAME, SP.LEAD, SP.FACE, SP.POSE, SP.LEAN, SP.SE
ENDING = getattr(SP, "ENDING", dict(kind="fall"))
INTRO = getattr(SP, "INTRO", "hop") # 冒頭の大きなハコブくんの動き: hop / rise / dash / drop / spin(2026-11-02 エイイチ「いろんな動き、いろんな向きに」)
MOVES = getattr(SP, "MOVES", []) # [(文, 秒, 動き, 長さ)] 動き: hop / shake / tilt / bow / spin / turn / dash / back / jump
BG = getattr(SP, "BG", None) # dict(file=背景の写真, at=(文, 秒) 暗くし始める所, veil=暗さ)。話によって写真を敷く
def vpages10(text):
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(stills=None):
N = _here("narration")
idx_main = json.load(open("tts/index.json"))
ei_main = set(N.EI_LINES)
lead = round(LEAD * FPS)
segs, t = [], lead
for k, sg in enumerate(N.SEGS):
_, face, text, pause = N.parse(sg)
o = idx_main[k]
frames = round((o["dur"] + pause) * FPS)
segs.append(dict(k=k, i=k, text=text, face=face, start=t, speech=round(o["dur"] * FPS), frames=frames, ei=k in ei_main))
t += frames
total = t
if total / FPS > 30.0:
sys.exit(f"{total / FPS:.1f}秒あります(30秒以内)")
def Tf(k, off=0.0):
if k == "end":
s = segs[-1]; return (s["start"] + s["speech"]) / FPS + 0.15 + off
return (segs[k]["start"] / FPS if k >= 0 else 0.0) + off
Sf = lambda k: segs[k]["speech"] / FPS
html = vpage(SP.HEAD, SP.scene(Tf, Sf), bg_layer(Tf))
os.makedirs("out/png", exist_ok=True)
from playwright.sync_api import sync_playwright
if stills:
with sync_playwright() as p:
br = p.chromium.launch(); pg = br.new_page(viewport={"width": W, "height": H})
open("_block.html", "w", encoding="utf-8").write(html)
pg.goto("file://" + os.path.abspath("_block.html")); pg.evaluate("document.fonts.ready"); pg.wait_for_timeout(300)
for s in stills:
pg.evaluate(f"window.setT({s})"); pg.screenshot(path=f"out/png/still_{s:05.2f}.png")
br.close()
print("総尺", round(total / FPS, 2)); return
# --- 声 ---
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[:s["frames"] * SR // FPS]
s0 = s["start"] * SR // FPS
pcm[s0:s0 + len(a)] = a
pcm = pcm[:total * SR // FPS]
# --- 字幕(ハコブくんは水色・エイイチは白)と画面 ---
subs = []
with sync_playwright() as p:
br = p.chromium.launch()
pg = br.new_page(viewport={"width": W, "height": H})
for s in segs:
fill = "#fff" if s["ei"] else HK_BLUE
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:{fill};stroke:#0D0D0D;stroke-width:12px;stroke-linejoin:round;paint-order:stroke fill}}")
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'')
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"out/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 = "out/_paper.mp4"
K.render_block(dict(name=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]
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 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, SHR = Tf(0), 0.5
lean = [(Tf(k, a), hgt, dx) for k, a, hgt, dx in LEAN]
def intro_pose(t):
"""冒頭の大きなハコブくん → (足もとの中心 x, 足もと y, 角度)"""
if INTRO == "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, 6 * np.sin(t * 1.3)
if INTRO == "dash": # 左から走ってきて、キキッと止まる
u = min(1.0, t / 0.55)
x = -400 + (560 + 400) * ease(u)
rot = -14 * (1 - u) if u < 1 else 10 * np.exp(-(t - 0.55) * 5) * np.sin((t - 0.55) * 16)
return x, 1900, rot
if INTRO == "drop": # 上から落ちてきて弾む
if t < 0.4:
return 540, -100 + 2000 * (t / 0.4) ** 2, 0
u = t - 0.4
return 540, 1900 - 180 * abs(np.sin(u * 7)) * np.exp(-u * 3), 5 * np.sin(u * 9) * np.exp(-u * 2)
if INTRO == "spin": # くるくる向きを変えながら跳ねる(向きは hk_mirror で)
ph = (t * 2.4) % 1.0
return 540, 1900 - 120 * 4 * ph * (1 - ph), 0
ph = (t * 1.9) % 1.0 # hop: 跳ねて喜ぶ
by = 1900 - 160 * 4 * ph * (1 - ph)
if t < 0.3:
by = 1920 + (by - 1920) * ease(t / 0.3)
return 540 + 30 * np.sin(t * 2.0), by, 8 * np.sin(t * 3.8)
moves = [(Tf(k, a), kind, d) for k, a, kind, d in MOVES]
def move_off(t):
"""文の途中の動き → (横, 縦, 角度, 左右反転)"""
dx = dy = dr = 0.0
mir = False
if t < t_end_big and INTRO == "spin":
mir = int(t * 6) % 2 == 1
for a, kind, d in moves:
if not (a <= t < a + d):
continue
u = (t - a) / d
if kind == "hop":
dy -= 70 * abs(np.sin(np.pi * u * 3))
elif kind == "shake":
dx += 18 * np.sin((t - a) * 45) * (1 - u)
elif kind == "tilt":
dr += 14 * np.sin(np.pi * u)
elif kind == "bow":
dr -= 22 * np.sin(np.pi * u)
elif kind == "spin":
mir = int(u * 8) % 2 == 1
dy -= 40 * abs(np.sin(np.pi * u * 4))
elif kind == "turn":
mir = True
elif kind == "dash":
dx += 240 * np.sin(np.pi * u)
dr += 8 * np.sin(np.pi * u)
elif kind == "back":
dx -= 130 * np.sin(np.pi * u)
dr -= 6 * np.sin(np.pi * u)
elif kind == "jump":
dy -= 260 * np.sin(np.pi * u)
dr += 360 * u if d >= 0.8 else 0
return dx, dy, dr, mir
def hk_place(t):
sx = HK_X + hs.w / 2
if t < t_end_big + SHR:
tt = min(t, t_end_big)
bx, by, rot = intro_pose(tt)
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 + (FOOT - by) * u, rot * (1 - u)
hh, dx = HK_SMALL_H, 0.0
for a, h2, d2 in lean: # 乗り出すときは 0.3 秒で寄って、少し行き過ぎて戻る
if t >= a:
u = min(1.0, (t - a) / 0.3)
k = ease(u) + (0.08 * np.sin(min(1, (t - a - 0.3) / 0.25) * np.pi) if t > a + 0.3 else 0)
hh, dx = hh + (h2 - hh) * k, dx + (d2 - dx) * k
rot = 4.0 * np.exp(-(t - lean[0][0]) * 3) * np.sin((t - lean[0][0]) * 12) if t > lean[0][0] else 0.0
return hh, sx + dx, FOOT, rot
# --- エイイチ(全身)---
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)
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_] = lay.crop((pad, 0, pad + im.width, pad + im.height))
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 POSE)
t_fall = Tf("end", 0.0)
t_end = Tf(*ENDING["at"]) if "at" in ENDING else t_fall
def ei_jump(t):
if ENDING["kind"] != "jump" or t < t_end:
return 0
u = t - t_end
return int(-120 * abs(np.sin(u * 7.0)) * np.exp(-u * 1.6)) if u < 2.0 else 0
def fall_deg(t):
if ENDING["kind"] != "fall":
return 0.0
t_fall_ = t_end
if t < t_fall_:
return 0.0
u = (t - t_fall_) / 0.35
if u < 1:
return -78 * ease(u)
return -78 + 6 * np.exp(-(t - t_fall_ - 0.35) * 7) * np.sin((t - t_fall_ - 0.35) * 22)
# --- 音 ---
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 SE:
f_ = os.path.join(K.BASE, "sound", name + ".wav")
if not os.path.exists(f_):
f_ = os.path.join(os.path.dirname(K.BASE), "_ショート専用", "_se_20261020", name + ".wav")
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", f_, "-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("out/_mix.wav", (mix * 32767).astype(np.int16))
# --- 重ねる ---
out = f"out/AIあるある{SP.NO:02d}_{NAME}_版{getattr(SP, 'VER', 1)}.mp4"
enc = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-",
"-i", "out/_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]
ex, ey = W - spr.width - 12, FOOT - spr.height + bob[f] + ei_jump(t)
deg = fall_deg(t)
if abs(deg) > 0.2:
img, (ox, oy) = rotate_about(spr, deg, (spr.width / 2, spr.height))
blend(bgf, np.asarray(img).astype(np.float32), ex + ox, ey + oy)
else:
blend(bgf, np.asarray(spr).astype(np.float32), ex, ey)
size, cx, fy, rot = hk_place(t)
mdx, mdy, mdr, mir = move_off(t)
cx, fy, rot = cx + mdx, fy + mdy, rot + mdr
img = (hb if size > HK_SMALL_H + 60 else hs).frame(params[f])
if mir:
img = ImageOps.mirror(img)
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}秒")
if __name__ == "__main__":
if len(sys.argv) > 2 and sys.argv[1] == "--still":
build([float(x) for x in sys.argv[2].split(",")])
else:
build()