Files
gaojie 920439e044
Sync to site1 / sync (push) Has been cancelled
Add presentation slides for OU process and Mehler formula
2026-06-03 04:08:01 +08:00

125 lines
4.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""把 Hermite 幻灯片 + 已有播客音频合成为 MP4 视频(无需重新调 TTS)。
流程:
1) 读已有完整音频时长;按每句文本字符数占比,把总时长分配到 11 页
(字符数是中文 TTS 时长的良好代理;总时长严格等于真实音频)。
2) 用 Chrome headless 把 11 页各截一张 1920x1080 PNG?page=N&clean=1)。
3) ffmpeg:每页图片按对应时长生成视频轨,叠加音频,输出 MP4。
用法:python3 build_video.py
"""
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT_JSON = os.path.join(HERE, "hermite_polynomials-script.json")
SLIDE_HTML = os.path.join(HERE, "slides", "Hermite 多项式.html")
OUT_DIR = os.path.join(HERE, "output")
SHOT_DIR = os.path.join(OUT_DIR, "shots")
AUDIO_IN = os.path.join(OUT_DIR, "hermite_polynomials-podcast.mp3")
VIDEO_OUT = os.path.join(OUT_DIR, "hermite_polynomials-video.mp4")
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
# 句(1-based) -> 页(1-based) 映射。共 43 句、11 页。
LINE_TO_PAGE = {
1: 1, 2: 1,
3: 2, 4: 2, 5: 2, 6: 2,
7: 3, 8: 3,
9: 4, 10: 4, 11: 4, 12: 4,
13: 5, 14: 5, 15: 5, 16: 5, 17: 5,
18: 6, 19: 6, 20: 6,
21: 7, 22: 7, 23: 7, 24: 7, 25: 7, 26: 7,
27: 8, 28: 8, 29: 8,
30: 9, 31: 9, 32: 9, 33: 9,
34: 10, 35: 10, 36: 10, 37: 10, 38: 10, 39: 10,
40: 11, 41: 11, 42: 11, 43: 11,
}
NUM_PAGES = 11
def run(cmd):
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def ffprobe_dur(path):
out = subprocess.check_output(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path])
return float(out.strip())
def step1_page_durations():
total = ffprobe_dur(AUDIO_IN)
with open(SCRIPT_JSON, encoding="utf-8") as f:
lines = json.load(f)["lines"]
# 每句字符数(去空白)作为时长权重
page_chars = {p: 0 for p in range(1, NUM_PAGES + 1)}
for i, line in enumerate(lines, 1):
n = len(line["paragraph"].strip())
page_chars[LINE_TO_PAGE.get(i, NUM_PAGES)] += n
grand = sum(page_chars.values())
page_dur = {p: total * page_chars[p] / grand for p in page_chars}
# 修正累计误差,使最后一页吸收余量
assigned = sum(page_dur.values())
page_dur[NUM_PAGES] += (total - assigned)
print(f"音频总时长 {total:.2f}s,按字符占比分配到 {NUM_PAGES} 页:")
for p in range(1, NUM_PAGES + 1):
print(f" page {p:>2}: {page_dur[p]:6.2f}s ({page_chars[p]} 字)")
return page_dur, total
def step2_screenshots():
os.makedirs(SHOT_DIR, exist_ok=True)
file_url = "file://" + SLIDE_HTML.replace(" ", "%20")
for p in range(1, NUM_PAGES + 1):
out = os.path.join(SHOT_DIR, f"page_{p:02d}.png")
url = f"{file_url}?page={p}&clean=1"
run([CHROME, "--headless=new", "--disable-gpu", "--hide-scrollbars",
"--force-device-scale-factor=1", "--window-size=1920,1080",
f"--screenshot={out}", "--virtual-time-budget=2800", url])
if not os.path.exists(out):
raise RuntimeError(f"截图失败 page {p}")
print(f"截图 page {p:02d}")
def step3_video(page_dur):
tmp_list = os.path.join(SHOT_DIR, "vlist.txt")
with open(tmp_list, "w") as lf:
for p in range(1, NUM_PAGES + 1):
img = os.path.join(SHOT_DIR, f"page_{p:02d}.png")
dur = max(page_dur[p], 0.8)
vid = os.path.join(SHOT_DIR, f"v_{p:02d}.mp4")
run(["ffmpeg", "-y", "-loop", "1", "-i", img, "-t", f"{dur:.3f}",
"-r", "30", "-pix_fmt", "yuv420p",
"-vf", "scale=1920:1080:force_original_aspect_ratio=decrease,"
"pad=1920:1080:(ow-iw)/2:(oh-ih)/2",
"-c:v", "libx264", "-preset", "medium", "-crf", "20", vid])
lf.write(f"file '{vid}'\n")
print(f"页视频 {p:02d} {dur:.2f}s ✓")
silent = os.path.join(SHOT_DIR, "silent.mp4")
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", tmp_list, "-c", "copy", silent])
run(["ffmpeg", "-y", "-i", silent, "-i", AUDIO_IN,
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", VIDEO_OUT])
print(f"\n✅ 视频已输出:{VIDEO_OUT} ({ffprobe_dur(VIDEO_OUT):.1f}s)")
def main():
if not os.path.exists(AUDIO_IN):
print(f"ERROR: 找不到音频 {AUDIO_IN}", file=sys.stderr)
sys.exit(1)
os.makedirs(OUT_DIR, exist_ok=True)
print("=== 步骤 1/3:按字符占比分配每页时长 ===")
page_dur, _ = step1_page_durations()
print("\n=== 步骤 2/3:截取 11 页幻灯片 ===")
step2_screenshots()
print("\n=== 步骤 3/3ffmpeg 合成视频 ===")
step3_video(page_dur)
if __name__ == "__main__":
main()