Add presentation slides for OU process and Mehler formula
Sync to site1 / sync (push) Has been cancelled
Sync to site1 / sync (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
#!/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/3:ffmpeg 合成视频 ===")
|
||||
step3_video(page_dur)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user