# -*- 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'{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"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()