# -*- coding: utf-8 -*- """考察回「壁打ちの型」の書き出しの部品(2026-11-02 1本目「頭脳は脳のように分かれる」で作った)。 紙(考察ノート)の上の文字の動きは HTML の CSS アニメーションで描き、時刻を止めて1コマずつ撮る(決まった絵になる)。 ハコブくん(口・目・リング・飛び出す・コケる)、エイイチのドット絵(声に合わせて小さく弾む)、字幕は、ここで重ねる。 使い方(動画の作業フォルダで。scenes_kabe.py に場面を書く) python3 kabe_kit.py sheet 場面ごとの静止画の一覧(一覧_場面.jpg) python3 kabe_kit.py render [名前..] 紙の映像を作る(できているものは使い回す) python3 kabe_kit.py build [--first-blocks N] 重ねて書き出す → out.mp4・out_確認用.mp4・out.srt """ import json, os, sys, subprocess, hashlib, html as _html, wave import numpy as np from PIL import Image, ImageFilter HERE = os.path.dirname(os.path.abspath(__file__)) for _c in (os.environ.get("P2M_BASE", ""), os.path.join(HERE, "..", "_制作ベース")): if _c and os.path.exists(os.path.join(_c, "hakobu.py")): BASE = os.path.abspath(_c); break sys.path.insert(0, HERE) sys.path.insert(1, BASE) os.environ.setdefault("P2M_BASE", BASE) from timing import FPS, SR, load, layout, narration, write_wav from subtitle import pages as sub_pages, split_lines, SUB_CX, SUB_MAXW, SUB_MAXC import hakobu as hk W, H = 1920, 1080 # ---------------- ハコブくんの顔を足す(ショートの動き hakobu_motion.py の顔の形だけを読む) ---------------- _src = open(os.path.join(BASE, "hakobu_motion.py"), encoding="utf-8").read().split("# ================= 体と背景の準備")[0] _ns = {"__name__": "hakobu_faces"} exec(compile(_src, "hakobu_motion(顔)", "exec"), _ns) hk.EXPR.setdefault("x", dict(eyes=("x", "x"), brows=None, rest="o", closed="o")) hk.EXPR.setdefault("dot", dict(eyes=("dot", "dot"), brows=None, rest="flat", closed="flat")) hk.EXPR.setdefault("down", dict(eyes=("down", "down"), brows="trouble", rest="frown", closed="frown")) # ---------------- 紙の CSS(動きの試しの base.css を本番用に) ---------------- CSS = r""" * { box-sizing: border-box; margin: 0; padding: 0; } html, body { width: 1920px; height: 1080px; overflow: hidden; background: #22201c; } .f { width: 1920px; height: 1080px; position: relative; overflow: hidden; background: #22201c; color: #22201c; font-family: "Noto Serif CJK JP", serif; } .paper { position: absolute; left: 430px; top: 64px; width: 1060px; height: 780px; background: #f5f3ee; box-shadow: 0 30px 60px rgba(0,0,0,.45); } .tag { position: absolute; left: 0; right: 0; top: 30px; text-align: center; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 500; font-size: 24px; letter-spacing: .2em; color: #5a564f; } .src { position: absolute; left: 0; right: 0; bottom: 24px; text-align: center; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 500; font-size: 20px; color: #8a857c; } .v { writing-mode: vertical-rl; } .center { position: absolute; left: 0; right: 0; top: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; } .stack { position: absolute; left: 0; right: 0; top: 96px; display: flex; flex-direction: column; align-items: center; gap: 22px; } .row2 { display: flex; gap: 32px; justify-content: center; } .card { position: relative; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 500; font-size: 34px; line-height: 1.5; padding: 16px 34px; text-align: center; } .card small { display: block; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 700; font-size: 20px; letter-spacing: .12em; margin-bottom: 4px; } .k-q { font-family: "Noto Serif CJK JP", serif; font-size: 46px; font-weight: 700; padding: 0 0 6px; } .k-fact { background: #22201c; color: #f5f3ee; } .k-fact small { color: #bdb7ab; } .k-mi { background: #dcd6c9; color: #22201c; } .k-mi small { color: #5a564f; } .k-hb { background: #ece9e2; color: #22201c; } .k-hb small { color: #5a564f; } .num { font-family: "Noto Serif CJK JP", serif; font-weight: 900; } .serif { font-family: "Noto Serif CJK JP", serif; } @keyframes show { to { opacity: 1; } } @keyframes hide { to { opacity: 0; } } [data-ink] > span { display: inline-block; opacity: 0; filter: blur(6px); animation: ink .45s ease-out forwards; } @keyframes ink { 0% { opacity: 0; filter: blur(6px); transform: translateY(-6px); } 100% { opacity: 1; filter: blur(0); transform: none; } } .land { display: inline-block; opacity: 0; animation: land .6s cubic-bezier(.2,1.6,.4,1) forwards; } @keyframes land { 0% { opacity: 0; transform: scale(1.5); filter: blur(4px); } 100% { opacity: 1; transform: scale(1); filter: blur(0); } } .pop { opacity: 0; animation: pop .45s ease-out forwards; } @keyframes pop { 0% { opacity: 0; transform: translateY(14px); } 100% { opacity: 1; transform: none; } } .wipe { clip-path: inset(0 100% 0 0); animation: wipe .45s ease-out forwards; } @keyframes wipe { to { clip-path: inset(0 0 0 0); } } .stamp { position: absolute; background: #c4472a; color: #f5f3ee; font-family: "Noto Sans CJK JP", sans-serif; font-size: 30px; font-weight: 900; padding: 10px 20px; letter-spacing: .08em; opacity: 0; animation: slam .35s cubic-bezier(.5,0,.75,0) forwards; white-space: nowrap; } @keyframes slam { 0% { opacity: 0; transform: rotate(-6deg) scale(2.6); } 85% { opacity: 1; transform: rotate(-6deg) scale(.94); } 100% { opacity: 1; transform: rotate(-6deg) scale(1); } } .botsu { position: absolute; right: -30px; top: -22px; background: #c4472a; color: #f5f3ee; font-family: "Noto Sans CJK JP", sans-serif; font-size: 26px; font-weight: 900; padding: 4px 14px; opacity: 0; animation: slam .35s cubic-bezier(.5,0,.75,0) forwards; } @keyframes fadeto { to { opacity: .5; } } .hl { background: linear-gradient(#e6dfcf,#e6dfcf) no-repeat left 85% / 0% 45%; animation: hl .5s ease-out forwards; } @keyframes hl { to { background-size: 100% 45%; } } .dassen { position: absolute; inset: 0; background-size: cover; background-position: center; opacity: 0; } @keyframes dIn { from { opacity: 0; transform: scale(1.3); } to { opacity: 1; transform: none; } } @keyframes dOut { to { opacity: 0; } } @keyframes fly { from { transform: none; } to { transform: translate(500px,-1100px) rotate(28deg); } } @keyframes back { from { transform: translate(-1400px,0) rotate(-8deg); } to { transform: none; } } @keyframes paperIn { from { opacity: 0; transform: translateY(40px) rotate(-1.2deg); } to { opacity: 1; transform: none; } } .big { position: absolute; left: 0; right: 0; text-align: center; transform: rotate(-5deg); font-family: "Noto Sans CJK JP", sans-serif; font-weight: 900; color: #fff; font-size: 120px; line-height: 1.2; -webkit-text-stroke: 4px #141311; text-shadow: 0 10px 0 #c4472a; } .big span { display: inline-block; opacity: 0; } @keyframes boom { from { opacity: 0; transform: scale(2.4) rotate(8deg); } to { opacity: 1; transform: none; } } .trow { display: flex; align-items: center; width: 760px; padding: 16px 30px; background: #e6e1d6; font-family: "Noto Sans CJK JP", sans-serif; font-weight: 500; font-size: 32px; text-align: left; } .trow b { width: 150px; font-weight: 900; } .trow i { margin-left: auto; font-style: normal; font-size: 24px; color: #5a564f; } .seg { font-family: "Noto Sans CJK JP", sans-serif; text-align: center; padding: 22px 10px; font-size: 30px; font-weight: 700; } """ JS = r"""""" def page(body, extra_css=""): return ('
' + body + '
' + JS + '') # ---------------- 紙の上の部品(時刻は場面の頭からの秒) ---------------- def ink(text, t, step=0.05, cls="", style=""): return f'{_html.escape(text)}' def land(text, t, style=""): return f'{text}' def card(kind, body, t, label=None, w=None, anim="pop", fade=None, botsu=None, style=""): """kind: fact(墨)/ mi(エイイチの見立て・濃い灰)/ hb(ハコブくん・薄い灰)/ q(問い)""" an = {"pop": f"pop .45s ease-out {t:.3f}s forwards", "wipe": f"wipe .45s ease-out {t:.3f}s forwards"}[anim] init = "opacity:0;" if anim == "pop" else "clip-path:inset(0 100% 0 0);" if fade is not None: an += f", fadeto .5s ease {fade:.3f}s forwards" lab = f"{label}" if label else "" bs = f'
ボツ
' if botsu is not None else "" wd = f"width:{w}px;" if w else "" return f'
{lab}{body}{bs}
' def stamp(text, t, style): return f'
{text}
' def tag(text): return f'
{text}
' def paper(inner, t_in=0.0, flights=(), style=""): """flights: [(飛んでいく秒, 戻る秒), ...] 脱線のときに紙が画面の外へ飛ぶ""" an = [f"paperIn .35s ease-out {t_in:.3f}s both"] if t_in is not None else [] for a, b in flights: an.append(f"fly .5s cubic-bezier(.5,0,.9,.4) {a:.3f}s forwards") an.append(f"back .5s cubic-bezier(.2,1,.4,1) {b:.3f}s forwards") return f'
{inner}
' def dassen(img, t_in, t_out, words="", word_style="top:200px", src_note=""): """画面いっぱいの写真・イラスト(t_in〜t_out)。words は大きな一言( は boom で出る)""" p = "file://" + os.path.abspath(img) out = (f'
') if words: spans, k = [], 0 for part in words.split("|"): if part == "
": spans.append("
"); continue st = "" if part.startswith("!"): part, st = part[1:], "color:#ffd84a;" spans.append(f'{part}') k += 1 out += f'
{"".join(spans)}
' if src_note: out += (f'
{src_note}
') return out def shake_js(windows, el="F", amp=5): """判子などで画面を少し揺らす(windows: [(開始秒, 長さ)])""" w = json.dumps(windows) return (f'') def counter_js(el, t0, t1, v1, dec=1, suffix=""): """数え上げ(t0〜t1 で 0→v1)""" return (f'') # ---------------- 場面の時刻 ---------------- class Timeline: def __init__(self, first=None): self.idx = load(first) self.total = layout(self.idx) def start(self, i): return self.idx[i]["start"] / FPS def speech(self, i): return self.idx[i]["speech"] / FPS def end(self, i): return (self.idx[i]["start"] + self.idx[i]["frames"]) / FPS # ---------------- ドット絵のエイイチ ---------------- def fill_teeth(im): """顔の中の透明な小さい穴(歯)を白で埋める(v4 の差分は歯が透明)""" from scipy import ndimage as nd a = np.array(im.convert("RGBA")) holes = a[..., 3] < 128 lab, n = nd.label(holes) edge = set(np.unique(np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]]))) | {0} for k in range(1, n + 1): if k in edge: continue m = lab == k if m.sum() < 80: a[m] = (250, 250, 246, 255) return Image.fromarray(a) class Eiichi: POSES = {1: "1_腕組み", 2: "2_うなずく", 3: "3_指さす", 4: "4_首をかしげる", 5: "5_ひらめく", 6: "6_のけぞって驚く", 7: "7_半目でツッコむ", 8: "8_吹き出して笑う"} def __init__(self, folder="src/ei"): self.raw = {} for k, n in self.POSES.items(): fs = [f for f in os.listdir(folder) if f.startswith(f"エイイチ_{n}_100px")] im = Image.open(os.path.join(folder, sorted(fs)[-1])).convert("RGBA") self.raw[k] = fill_teeth(im) self._cache = {} def sprite(self, pose, scale=4, glow=False, crop=1.0): key = (pose, scale, glow, crop) if key not in self._cache: im = self.raw[pose] im = im.resize((im.width * scale, im.height * scale), Image.NEAREST) if crop < 1.0: im = im.crop((0, 0, im.width, int(im.height * crop))) if glow: pad = 60 g = Image.new("RGBA", (im.width + 2 * pad, im.height + 2 * pad), (0, 0, 0, 0)) al = Image.new("L", g.size, 0); al.paste(im.split()[3], (pad, pad)) al = al.filter(ImageFilter.GaussianBlur(28)).point(lambda v: int(v * 0.35)) g = Image.merge("RGBA", (Image.new("L", g.size, 245), Image.new("L", g.size, 243), Image.new("L", g.size, 238), al)) g.alpha_composite(im, (pad, pad)) im = g self._cache[key] = (np.array(im).astype(np.float32), (-60 if glow else 0)) return self._cache[key] def blend(dst, src, x, y, alpha=1.0): """dst: H×W×3 uint8、src: h×w×4 float(0-255)を (x,y) に重ねる(はみ出しは切る)""" h, w = src.shape[:2] DH, DW = dst.shape[:2] # 縦長(ショート)でも使えるように、重ね先の大きさで切る x0, y0, x1, y1 = max(0, x), max(0, y), min(DW, x + w), min(DH, y + h) if x0 >= x1 or y0 >= y1: return s = src[y0 - y:y1 - y, x0 - x:x1 - x] a = s[..., 3:4] / 255.0 * alpha d = dst[y0:y1, x0:x1].astype(np.float32) dst[y0:y1, x0:x1] = (d * (1 - a) + s[..., :3] * a + 0.5).clip(0, 255).astype(np.uint8) def rotate_about(img, deg, pivot, pad=760): """img(PIL RGBA)を pivot(img 内の座標)のまわりに回す。返り値: (回した画像, img の左上からのずれ)""" c = Image.new("RGBA", (img.width + 2 * pad, img.height + 2 * pad), (0, 0, 0, 0)) c.paste(img, (pad, pad)) c = c.rotate(-deg, resample=Image.BICUBIC, center=(pad + pivot[0], pad + pivot[1])) bb = c.getbbox() if not bb: return c, (-pad, -pad) return c.crop(bb), (bb[0] - pad, bb[1] - pad) def ease(u): u = min(1.0, max(0.0, u)) return u * u * (3 - 2 * u) # ---------------- 字幕 ---------------- SUBCSS = """*{margin:0;padding:0}html,body{width:1920px;height:1080px;background:transparent} svg{position:absolute;left:0;top:0} .t{font-family:'Noto Sans CJK JP',sans-serif;font-size:52px;font-weight:800;fill:#fff;stroke:#0D0D0D;stroke-width:11px;stroke-linejoin:round;stroke-linecap:round;paint-order:stroke fill}""" def fill_holes(path, ink=(13, 13, 13), maxarea=400): from scipy import ndimage as _nd a = np.array(Image.open(path).convert('RGBA')).astype(np.float32) al = a[..., 3] / 255.0 lab, n = _nd.label(al < 0.98) if not n: return edge = set(np.unique(np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]]))) | {0} sz = _nd.sum(np.ones_like(al), lab, range(1, n + 1)) keep = np.array([0] + [(k not in edge and sz[k - 1] <= maxarea) for k in range(1, n + 1)]) m = keep[lab].astype(bool) if not m.any(): return for c in range(3): a[..., c][m] = a[..., c][m] * al[m] + ink[c] * (1 - al[m]) a[..., 3][m] = 255 Image.fromarray(a.clip(0, 255).astype(np.uint8)).save(path) def make_subs(idx, pg): """render.py と同じ字幕(中央・白・黒縁・2行まで)。png/sub/{番号}_{枚目}.png""" os.makedirs("png/sub", exist_ok=True) bad = [] for o in idx: if not o["text"]: continue for pk, ptxt in enumerate(sub_pages(o["text"])): lines = split_lines(ptxt) if ptxt else None if lines is None: bad.append(f"字幕 {o['i']:03d}: 2行に割れない「{o['text']}」"); continue out = f"png/sub/{o['i']:03d}_{pk}.png" key = hashlib.md5(repr(lines).encode()).hexdigest()[:10] if os.path.exists(out) and os.path.exists(out + ".key") and open(out + ".key").read() == key: continue svg_lines = ''.join(f'{_html.escape(ln)}' for ln in lines) pg.set_content(f""" {svg_lines}""") pg.evaluate(f"""() => {{ const ts=[...document.querySelectorAll('text.t')]; const lh=72; const n=ts.length; let fs=52; for (const t of ts){{ const w=t.getComputedTextLength(); if (w>{SUB_MAXW}) fs=Math.min(fs, Math.floor(52*{SUB_MAXW}/w)); }} ts.forEach((t,k)=>{{ t.style.fontSize=fs+'px'; t.setAttribute('y', 1080-62-(n-1-k)*lh); }}); }}""") pg.screenshot(path=out, omit_background=True) fill_holes(out) open(out + ".key", "w").write(key) if bad: sys.exit("\n".join(bad)) def sub_layer(path): im = Image.open(path).convert("RGBA") bb = im.getbbox() return np.array(im.crop(bb)).astype(np.float32), bb[0], bb[1] # ---------------- 紙の映像を撮る ---------------- def block_hash(b, n): return hashlib.md5((b.get("html", "") + repr(b.get("video")) + str(n)).encode()).hexdigest()[:10] def render_block(b, n, out, pg=None): """b: dict(name, html | video=(映像, 開始秒, テロップpng)), n: コマ数 → out(mp4・crf 14)""" tmp = out[:-4] + ".part.mp4" if "video" in b: src, ss, ovl = b["video"] ins = ["-ss", f"{ss:.3f}", "-stream_loop", "-1", "-i", src] fc = f"[0:v]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,fps={FPS},setsar=1[b]" if ovl: ins += ["-loop", "1", "-i", ovl] fc += ";[b][1:v]overlay[v]" else: fc += ";[b]null[v]" subprocess.run(["ffmpeg", "-v", "error", "-y"] + ins + ["-filter_complex", fc, "-map", "[v]", "-frames:v", str(n), "-c:v", "libx264", "-preset", "ultrafast", "-crf", "14", "-pix_fmt", "yuv420p", "-r", str(FPS), "-an", tmp], check=True) os.replace(tmp, out) return with open("_block.html", "w", encoding="utf-8") as f: f.write(b["html"]) pg.goto("file://" + os.path.abspath("_block.html")) pg.evaluate("document.fonts.ready") pg.wait_for_timeout(300) p = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "image2pipe", "-framerate", str(FPS), "-c:v", "mjpeg", "-i", "-", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "14", "-pix_fmt", "yuv420p", "-r", str(FPS), "-an", tmp], stdin=subprocess.PIPE) last, shots = None, 0 for f in range(n): t = f / FPS if last is None or pg.evaluate(f"window.busy({t})"): pg.evaluate(f"window.setT({t})") last = pg.screenshot(type="jpeg", quality=93); shots += 1 p.stdin.write(last) p.stdin.close() if p.wait(): sys.exit(f"紙の映像の書き出しに失敗: {b['name']}") os.replace(tmp, out) print(f" {b['name']}: {n}コマ(撮影 {shots})", flush=True) def still(b, t, pg, path): with open("_block.html", "w", encoding="utf-8") as f: f.write(b["html"]) pg.goto("file://" + os.path.abspath("_block.html")) pg.evaluate("document.fonts.ready") pg.wait_for_timeout(200) pg.evaluate(f"window.setT({t})") pg.screenshot(path=path, type="jpeg", quality=90) # ================= 重ねる(ハコブくん・エイイチ・字幕) ================= class Cast: """フレームごとのハコブくんとエイイチの姿を決めて、背景に重ねる""" def __init__(self, sc, last_i): self.sc, self.idx = sc, sc.tl.idx[:last_i + 1] idx = self.idx self.total = idx[-1]["start"] + idx[-1]["frames"] ei_lines = set(_ei_lines()) self.ei_lines = ei_lines pcm = narration(idx) self.pcm = pcm # --- ハコブくん --- self.hu = hk.Hakobu() self.hf = hk.Hakobu(mode="full") bb = self.hf.body.getbbox() self.hf.w = self.hu.w self.hf.h = round((bb[3] - bb[1]) * self.hu.w / (bb[2] - bb[0])) self.hf._cache = {} tline = [(o["start"], o["start"] + o["frames"], o["start"] + o["speech"], o.get("face", "base"), bool(o["text"]) and o["i"] not in ei_lines) for o in idx] mp = pcm.astype(np.float32) / 32768.0 speak = np.zeros(self.total, bool) eispk = np.zeros(self.total, bool) for o in idx: a0, a1 = int(o["start"] / FPS * SR), int((o["start"] + o["frames"]) / FPS * SR) if o["i"] in ei_lines: mp[a0:a1] = 0 eispk[o["start"]:o["start"] + o["speech"]] = True elif o["text"]: speak[o["start"]:o["start"] + o["speech"]] = True self.params = self.hu.animate(tline, mp) for t0, t1, f in sc.HK_FACE: # 文の途中で替える表情 ex = hk.EXPR[f] for i in range(max(0, int(t0 * FPS)), min(self.total, int(t1 * FPS))): eyes, brows, mouth, ring, dy = self.params[i] if not speak[i]: mouth = ex["rest"] elif mouth == "closed" and "closed" in ex: mouth = ex["closed"] self.params[i] = (ex["eyes"], ex["brows"], mouth, ring, dy) # --- エイイチ: 声に合わせて 4px(ドット1つ)弾む --- 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(self.total)]) ref = np.percentile(rms[rms > 0.01], 85) if (rms > 0.01).any() else 1.0 bob = np.zeros(self.total, int) for i in range(0, self.total, 3): v = -4 if rms[i:i + 3].mean() > 0.45 * ref else 0 bob[i:i + 3] = v self.bob = bob self.ei = Eiichi() self.subs = {} def ei_state(self, f): t = f / FPS pose = 1 for a, p in self.sc.EI_POSE: if t >= a: pose = p place = "normal" for a, b, k in self.sc.EI_PLACE: if a <= t < b: place, pa, pb = k, a, b x, y, scale, glow, crop = 1490, 680, 4, False, 1.0 if place == "hide": return None if place == "big": scale, glow, x, y = 5, True, 1920 - 60 - 500, 1080 - 500 elif place == "buried": scale, crop = 3, 0.62 x, y = 840, 870 - int(300 * 0.62) # 紙の山の真ん中の黒い穴から顔を出す(字幕より上) elif place == "slide": pose = 3 go = ease((t - pa) / 0.7) back = ease((t - (pb - 0.5)) / 0.5) x = int(1490 + (520 - 1490) * go + (1490 - 520) * back) x -= x % 4 y += self.bob[f] if place in ("normal", "slide") else 0 return pose, x, y, scale, glow, crop def hk_state(self, f): """→ (体, dx, dy, 回す角度, 回す中心) 体は upper / full""" t = f / FPS for m in self.sc.HK_MOVES: u = t - m["t0"] if m["kind"] == "jump" and 0 <= u < m["dur"]: y0 = self.hu.y y_peak = H - self.hf.h - 30 if u < 0.32: k = np.sin(u / 0.32 * np.pi / 2) * 1.12 elif u < 0.5: k = 1.12 - 0.12 * ease((u - 0.32) / 0.18) elif u < m["dur"] - 0.45: k = 1.0 + 0.02 * np.sin((u - 0.5) * 6) else: k = 1.0 - ease((u - (m["dur"] - 0.45)) / 0.45) ytop = y0 + (y_peak - y0) * k rot = -7 * min(1, k) + 2.5 * np.sin(u * 5) * min(1, k) return "full", 30 * min(1, k), ytop - self.hf.y, rot, (self.hf.w / 2, self.hf.h) if m["kind"] == "reflex" and 0 <= u < m["dur"]: e = ease(u / 0.1) * (1 - ease((u - 0.25) / (m["dur"] - 0.25))) # 上半身の絵を回すと、下の切り口が斜めに見える(2026-11-02 エイイチの指摘)。全身の絵を、上半身と同じ位置に置いて回す return "full", -24 * e, self.hu.y - self.hf.y, -16 * e, (0, self.hf.h) if m["kind"] == "fall": # 全身を出してから、前のめりに倒れる(体が切れないように。2026-11-02 エイイチの指摘) y0 = self.hu.y ys = H - 14 - self.hf.h # 全身が見える位置(足もとが画面の下から14px) piv = (self.hf.w * 0.95, self.hf.h) t1 = m["t1"] if 0 <= u < 0.18: k = ease(u / 0.18) return "full", 0, y0 + (ys - y0) * k - self.hf.y, -6 * k, piv if 0.18 <= u < 0.5: r = -6 + 98 * ease((u - 0.18) / 0.32) return "full", 0, ys - self.hf.y, r, piv if u >= 0.5 and t < t1: r = 88 + 4 * np.exp(-(u - 0.5) * 8) * np.cos((u - 0.5) * 28) return "full", 0, ys - self.hf.y, r, piv if t1 <= t < t1 + 0.45: return "full", 0, ys - self.hf.y, 88 * (1 - ease((t - t1) / 0.45)), piv if t1 + 0.45 <= t < t1 + 0.75: k = ease((t - t1 - 0.45) / 0.3) return "full", 0, ys + (y0 - ys) * k - self.hf.y, 0, piv return "upper", 0, 0, 0, None def hk_alpha(self, f): t = f / FPS for a, b, al in self.sc.HK_ALPHA: if a <= t < b: return al return 1.0 def sub_at(self, f): for o in self.idx: if o["start"] <= f < o["start"] + o["frames"]: if not o["text"]: return None r = f - o["start"] for k, a, b in o["parts"]: if a <= r < b: key = (o["i"], k if k is not None else 0) if key not in self.subs: self.subs[key] = sub_layer(f"png/sub/{key[0]:03d}_{key[1]}.png") return self.subs[key] return None def draw(self, bg, f): """bg: H×W×3 uint8(書き換える)""" st = self.ei_state(f) if st: pose, x, y, scale, glow, crop = st spr, off = self.ei.sprite(pose, scale, glow, crop) blend(bg, spr, x + off, y + off) body, dx, dy, rot, pivot = self.hk_state(f) hh = self.hu if body == "upper" else self.hf img = hh.frame(self.params[f]) x, y = hh.x + int(round(dx)), hh.y + int(round(dy)) if rot: img, (ox, oy) = rotate_about(img, rot, pivot) x, y = x + ox, y + oy blend(bg, np.asarray(img).astype(np.float32), x, y, self.hk_alpha(f)) s = self.sub_at(f) if s is not None: blend(bg, s[0], s[1], s[2]) return bg def _ei_lines(): import importlib.util as ilu sp = ilu.spec_from_file_location("_narr_here", os.path.join(os.getcwd(), "narration.py")) nm = ilu.module_from_spec(sp); sp.loader.exec_module(nm) return list(getattr(nm, "EI_LINES", [])) def prepare(sc, blocks, pg): os.makedirs("png/ovl", exist_ok=True); os.makedirs("blocks", exist_ok=True) for k, h in sc.OVERLAYS.items(): pg.set_content(h); pg.wait_for_timeout(80) pg.screenshot(path=f"png/ovl/{k}.png", omit_background=True) last_i = blocks[-1]["sents"][-1] make_subs(sc.tl.idx[:last_i + 1], pg) def block_frames(sc, b): idx = sc.tl.idx a = idx[b["sents"][0]]["start"] e = idx[b["sents"][-1]]["start"] + idx[b["sents"][-1]]["frames"] return a, e - a def main(): args = sys.argv[1:] cmd = args[0] if args else "sheet" import importlib sc = importlib.import_module("scenes_kabe") from playwright.sync_api import sync_playwright blocks = sc.BLOCKS if "--first-blocks" in args: blocks = blocks[:int(args[args.index("--first-blocks") + 1])] names = [a for a in args[1:] if not a.startswith("--") and not a.isdigit()] with sync_playwright() as p: br = p.chromium.launch() pg = br.new_page(viewport={"width": W, "height": H}) prepare(sc, blocks, pg) if cmd == "sheet": cast = Cast(sc, blocks[-1]["sents"][-1]) os.makedirs("png/sheet", exist_ok=True) shots = [] for b in blocks: if names and b["name"] not in names: continue a, n = block_frames(sc, b) ts = sc.SHEET.get(b["name"], [n / FPS - 0.4]) for k, t in enumerate(ts): f = min(a + int(t * FPS), a + n - 1) path = f"png/sheet/{b['name']}_{k}.jpg" if "video" in b: src, ss, ovl = b["video"] dur = float(subprocess.check_output(["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", src])) subprocess.run(["ffmpeg", "-v", "error", "-y", "-ss", f"{(ss + t) % (dur - 0.2):.2f}", "-i", src, "-frames:v", "1", "-vf", "scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080", path], check=True) im = Image.open(path).convert("RGBA") im.alpha_composite(Image.open(ovl).convert("RGBA")) im.convert("RGB").save(path) else: still(b, t, pg, path) bg = np.array(Image.open(path).convert("RGB")) Image.fromarray(cast.draw(bg, f)).save(path, quality=88) shots.append((b["name"], k, f / FPS, path)) # 一覧 cols, tw = 4, 480 th = tw * 9 // 16 rows = (len(shots) + cols - 1) // cols from PIL import ImageDraw, ImageFont fnt = ImageFont.truetype("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", 22) sheet = Image.new("RGB", (cols * tw, rows * (th + 34)), (240, 238, 232)) d = ImageDraw.Draw(sheet) for j, (nm, k, t, path) in enumerate(shots): x, y = (j % cols) * tw, (j // cols) * (th + 34) sheet.paste(Image.open(path).resize((tw - 6, th - 4)), (x + 3, y + 32)) d.text((x + 8, y + 4), f"{nm} {int(t // 60)}:{t % 60:04.1f}", font=fnt, fill=(30, 30, 30)) sheet.save("一覧_場面.jpg", quality=85) print("一覧_場面.jpg", len(shots)) if cmd in ("render", "build"): for b in blocks: if names and b["name"] not in names: continue a, n = block_frames(sc, b) out = f"blocks/{b['name']}_{block_hash(b, n)}.mp4" b["mp4"] = out if not os.path.exists(out): render_block(b, n, out, pg) br.close() if cmd != "build": return for b in blocks: a, n = block_frames(sc, b) b["mp4"] = f"blocks/{b['name']}_{block_hash(b, n)}.mp4" last_i = blocks[-1]["sents"][-1] cast = Cast(sc, last_i) idx = cast.idx write_wav("narration.wav", cast.pcm) audio_in = "narration.wav" if os.path.exists("sound.py"): from audio_mix import mix audio_in = mix(idx, cast.pcm) total = cast.total enc = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", "-i", audio_in, "-map", "0:v", "-map", "1:a", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-movflags", "+faststart", "-frames:v", str(total), "out.mp4"], stdin=subprocess.PIPE) f = 0 for b in blocks: a, n = block_frames(sc, b) dec = subprocess.Popen(["ffmpeg", "-v", "error", "-i", b["mp4"], "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE) for k in range(n): raw = dec.stdout.read(W * H * 3) if len(raw) < W * H * 3: sys.exit(f"紙の映像が短い: {b['name']} {k}/{n}") bg = np.frombuffer(raw, np.uint8).reshape(H, W, 3).copy() enc.stdin.write(cast.draw(bg, a + k).tobytes()) f += 1 if f % 900 == 0: print(f" {f}/{total}", flush=True) dec.stdout.close(); dec.wait() enc.stdin.close() if enc.wait(): sys.exit("書き出しに失敗") # 字幕 SRT def ts(fr): ms = round(fr / FPS * 1000); h_, ms = divmod(ms, 3600000); m_, ms = divmod(ms, 60000); s_, ms = divmod(ms, 1000) return f"{h_:02d}:{m_:02d}:{s_:02d},{ms:03d}" srt, k = [], 1 for o in idx: if not o["text"]: continue pages_ = sub_pages(o["text"]) for (pk, a_, b_), ptxt in zip(o["parts"], pages_): end = min(b_, o["speech"]) if pk == len(pages_) - 1 else b_ srt.append(f"{k}\n{ts(o['start'] + a_)} --> {ts(o['start'] + end)}\n{ptxt}\n"); k += 1 open("out.srt", "w", encoding="utf-8").write("\n".join(srt)) json.dump(idx, open("timeline.json", "w"), ensure_ascii=False) for crf in (29, 32, 35): subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", "out.mp4", "-c:v", "libx264", "-preset", "medium", "-crf", str(crf), "-c:a", "aac", "-b:a", "112k", "-movflags", "+faststart", "out_確認用.mp4"], check=True) mb = os.path.getsize("out_確認用.mp4") / 1e6 if mb <= 30: break print(f"frames {total} = {total / FPS:.1f}s 確認用 {mb:.1f}MB (crf {crf})") if __name__ == "__main__": main()