"""マンガのショート(第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), "秒")