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