用代码做自媒体(二):用 Claude Code skill 把博客文章自动做成讲解视频(本机渲染+edge-tts 配音)

把一篇技术博客做成「讲解幻灯片 + 中文配音」的视频,不用剪辑软件、不用录音、不用对轴。

这篇是手把手实战:在你自己的 Mac 上,跟着第一步、第二步……敲下去,大约半小时出第一条视频。

整套画面逐帧画、配音用免费神经 TTS 念、合成用 ffmpeg——全程本机,不碰任何「文生视频」大模型。所以稳定、可复现、几乎零成本;代价是画面为讲解幻灯片风格,不是真人实拍。

跑通之后,还能把这套脚本包成一个 Claude Code 的 skill,以后在对话里直接说一句「把这篇博客做成视频」,让 Claude 替你跑(见最后的进阶部分)。

先确认两件事,免得白忙:① 你用的是 macOS(本文命令都在 mac 上实测;Windows/Linux 思路一样,但字体路径要自己改)。② 全程只有"配音"那一下需要联网。

整条路就这 5 步,照着做:

五步出片:装环境 → 装脚本 → 切幻灯片 → 出预览 → 出成片

第一部分 · 准备工作(只做一次)

做视频前,要在电脑上备齐工具和脚本。这一整部分只做一次,以后再出片直接从第二部分开始。

1.1 打开终端

后面每一步的命令,都敲在 macOS 的终端里。

打开方法:按 Command + 空格,输入「终端」(或 Terminal)回车,出来一个会闪光标的窗口。

之后把下面每个代码块整段复制、粘进去、回车即可。代码块右上角一般有「复制」按钮,点它最稳。


1.2 装环境:ffmpeg + Python 库 + 配音工具

第一次用要装三样东西:ffmpeg(合成视频)、Pillow+numpy(画面和音频)、edge-tts(配音)。直接整段复制进终端回车:

# 1) 如果没装过 Homebrew(mac 的软件管家),先装它;装过可跳过这行
command -v brew >/dev/null || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 2) 装 ffmpeg
brew install ffmpeg

# 3) 装画图/算音频的 Python 库
pip3 install Pillow numpy || pip3 install --break-system-packages Pillow numpy

# 4) 给配音工具单开一个环境并安装(路径固定,脚本里就认这个)
python3 -m venv ~/.venv-tts && ~/.venv-tts/bin/pip install edge-tts

怎么算装好了? 跑下面这行,最后能打印一句「环境 OK」就成:

ffmpeg -version >/dev/null && python3 -c "import PIL,numpy" && ~/.venv-tts/bin/edge-tts --voice zh-CN-YunxiNeural --text 测试 --write-media /tmp/t.mp3 && echo "环境 OK"

这一步只做一次,以后出片不用再装。如果第 1 步装 Homebrew 后提示你"再敲两行把 brew 加进 PATH",照终端里的提示做完再继续。


1.3 装脚本和一篇示例文章

这一步会在你的用户目录下建一个 blog2video 文件夹,自动写好全部脚本和一篇示例文章——你不用手动新建任何文件,整段复制进终端、回车就行(代码很长,但只是一次粘贴):

点此展开安装脚本(很长,复制一次即可;已内嵌全部脚本源码)
mkdir -p ~/blog2video

# —— 示例文章(先用它跑通,之后换成你自己的) ——
cat > ~/blog2video/article.md <<'ARTICLE_EOF'
# 用 Claude 把博客做成讲解视频

把写好的一篇博客交给 Claude,它就能帮你做成带配音、带动画的讲解视频。全程在自己电脑上完成,不用剪辑软件、不用录音。

## 它在背后做了什么

画面由程序一帧一帧画出来,配音用免费的神经语音合成,最后用 ffmpeg 把画面和声音合成 mp4。全程本机,不依赖文生视频大模型。

## 出片就三步

先把文章切成一页页幻灯片;再出一个十五秒预览看看风格;满意了,最后出完整视频。

## 为什么先出预览

完整视频要渲染好几分钟。先出十五秒预览定好风格,再出完整版,免得白等、省下时间。

## 让 Claude 一句话代劳

把这套脚本包成一个 skill,以后只要对 Claude 说一句把这篇做成视频,它就自动帮你跑完。
ARTICLE_EOF

# —— make_slides.py ——
cat > ~/blog2video/make_slides.py <<'MAKE_SLIDES_EOF'
#!/usr/bin/env python3
"""
Markdown → slides.json 初稿生成器。
按 ## 小节切页,自动识别代码块/引用/列表/段落为 blocks,并抽取正文做旁白草稿。
  python3 make_slides.py article.md slides.json --title "文章标题" [--subtitle "副标题"]

⚠️ 产出是【初稿】:旁白是正文直接清洗来的,务必人工精编——
   改成口语、结论先行、别照读代码、数字符号写成口语(<8→小于8, 15m→十五分钟)。
"""
import argparse, json, re

def clean(s):
    """去 Markdown 标记,留纯文本(用于显示和旁白)。"""
    s = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", s)   # 链接/图片 → 文字
    s = re.sub(r"[*_`]+", "", s)                        # 去 **粗体** `代码` *斜体*
    s = re.sub(r"^\s*#+\s*", "", s)                     # 行内残留井号
    return s.strip()

def parse(md):
    lines = md.split("\n")
    intro, sections, cur = [], [], None
    i = 0
    in_code, code_buf = False, []
    while i < len(lines):
        ln = lines[i]
        if ln.strip().startswith(chr(96) * 3):   # chr(96)*3 == 三个反引号
            if not in_code:
                in_code, code_buf = True, []
            else:
                if cur:
                    cur["blocks"].append(("code", "\n".join(code_buf)))
                in_code = False
            i += 1; continue
        if in_code:
            code_buf.append(ln); i += 1; continue
        m = re.match(r"^##\s+(.*)", ln)          # 只按 ## 切页(# 是大标题,### 并入)
        if m:
            cur = {"title": clean(m.group(1)), "blocks": [], "_narr": []}
            sections.append(cur); i += 1; continue
        text = ln.strip()
        if not text:
            i += 1; continue
        target = cur if cur else None
        if target is None:
            intro.append(text); i += 1; continue
        if text.startswith(">"):
            t = clean(text.lstrip(">").strip())
            cur["blocks"].append(("note", t)); cur["_narr"].append(t)
        elif re.match(r"^([-*]|\d+\.)\s+", text):
            t = clean(re.sub(r"^([-*]|\d+\.)\s+", "", text))
            cur["blocks"].append(("bullet", t)); cur["_narr"].append(t)
        else:
            t = clean(text)
            cur["blocks"].append(("plain", t)); cur["_narr"].append(t)
        i += 1
    return [clean(x) for x in intro], sections

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("md"); ap.add_argument("out")
    ap.add_argument("--title", required=True)
    ap.add_argument("--subtitle", default="")
    ap.add_argument("--ep", default="", help="封面 EP 徽标文字(留空则不显示)")
    a = ap.parse_args()
    intro, sections = parse(open(a.md, encoding="utf-8").read())

    # 首页 = 海报式封面(标题/副标题取自 cnblogs 文章,动态生成)
    slides = [{
        "cover": True,
        "ep": a.ep,
        "title": a.title,
        "subtitle": a.subtitle or (intro[0][:40] if intro else ""),
        "narration": " ".join(intro) or a.title,
    }]
    for sec in sections:
        slides.append({
            "title": sec["title"],
            "blocks": [list(b) for b in sec["blocks"]],
            "narration": " ".join(sec["_narr"]) or sec["title"],
        })
    json.dump(slides, open(a.out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
    print(f"✅ 初稿: {a.out}  ({len(slides)} 页)")
    print("⚠️ 旁白是正文直清洗的草稿,务必精编:口语化、结论先行、别照读代码、数字符号写成口语。")

if __name__ == "__main__":
    main()
MAKE_SLIDES_EOF

# —— sfx.py ——
cat > ~/blog2video/sfx.py <<'SFX_EOF'
#!/usr/bin/env python3
"""
本机合成轻量音效(numpy,零版权):切页"嗖"、要点弹出"啵"、结尾撒花"叮咚"。
被 md2video.py 调用,垫在人声/BGM 下增加活泼感(音量很轻,不喧宾夺主)。
"""
import wave
import numpy as np

SR = 44100

def whoosh(sr=SR):
    """切页嗖声:窄带噪声 + 起伏包络。"""
    n = int(0.32 * sr)
    nz = np.random.RandomState(1).randn(n).astype(np.float32)
    nz = np.convolve(nz, np.ones(70) / 70, "same")          # 低通成"风声"
    env = np.sin(np.linspace(0, np.pi, n)) ** 1.6
    return (nz * env * 0.9).astype(np.float32)

def pop(sr=SR):
    """要点弹出啵声:快速下滑的短促音。"""
    n = int(0.12 * sr); t = np.arange(n) / sr
    f = np.linspace(720, 190, n)
    ph = 2 * np.pi * np.cumsum(f) / sr
    return (np.sin(ph) * np.exp(-t * 28)).astype(np.float32)

def sparkle(sr=SR):
    """结尾撒花:几个高音铃声错落。"""
    n = int(0.95 * sr); out = np.zeros(n, np.float32)
    for i, fr in enumerate([1568, 1976, 2349, 2637, 3136]):
        st = int(i * 0.11 * sr); m = n - st
        if m <= 0:
            continue
        t = np.arange(m) / sr
        out[st:st + m] += np.sin(2 * np.pi * fr * t) * np.exp(-t * 6.5)
    return (out * 0.6).astype(np.float32)

def build(dur, appears, is_title, is_ending, sr=SR, master=0.4):
    """生成 dur 秒的音效轨(mono float32)。appears=要点出现时刻列表。"""
    n = int(dur * sr); track = np.zeros(n, np.float32)

    def place(sig, t, g):
        s = int(t * sr); e = min(s + len(sig), n)
        if 0 <= s < n:
            track[s:e] += sig[:e - s] * g

    place(whoosh(sr), 0.02, 0.45)                  # 切页
    for a in appears:
        if a > 0.01:
            place(pop(sr), a, 0.5)                  # 每个要点
    if is_ending:
        place(sparkle(sr), 0.55, 0.85)             # 撒花
    return track * master

def write_wav(path, mono, sr=SR):
    pcm = (np.clip(mono, -1, 1) * 32767).astype(np.int16)
    with wave.open(path, "wb") as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(sr)
        w.writeframes(pcm.tobytes())
SFX_EOF

# —— gen_bgm.py ——
cat > ~/blog2video/gen_bgm.py <<'GEN_BGM_EOF'
#!/usr/bin/env python3
"""
本机合成背景音乐(numpy,零版权)。两种风格:
  - ballad : 原创轻柔钢琴民谣(暖心、舒缓、童谣感,贴近温情钢琴小品风格;旋律为原创,非任何现有曲目)
  - pad    : 缓慢和弦氛围 pad
被 md2video.py 调用,也可单独跑:
  python3 gen_bgm.py 240 out.wav            # 默认 ballad
  python3 gen_bgm.py 240 out.wav pad
"""
import sys, wave
import numpy as np

SR = 44100

def midi_hz(m):
    return 440.0 * 2 ** ((m - 69) / 12.0)

def lowpass(x, k=24):
    return np.convolve(x, np.ones(k, np.float32) / k, mode="same").astype(np.float32)

# ============ 风格一:原创钢琴民谣 ============
BPM = 76                     # 再快一点点
BEAT = 60.0 / BPM            # 一拍秒数
BAR = 4 * BEAT               # 4/4 一小节

# 和弦走向(每小节一个),C 大调,温暖民谣常用进行(原创编排)
CHORD_ROOTS = [48, 55, 57, 52, 53, 48, 50, 55]            # C  G  Am Em F  C  Dm G
CHORD_TONES = [
    [48, 52, 55], [55, 59, 62], [57, 60, 64], [52, 55, 59],
    [53, 57, 60], [48, 52, 55], [50, 53, 57], [55, 59, 62],
]
# 原创主旋律:(MIDI 音高 或 None=休止, 时值拍数)。稀疏、留白多、收在中音区(更安静)
MELODY = [
    (None, 1), (64, 1), (67, 2),        # 第1小节
    (62, 2), (None, 2),                 # 第2  留白
    (60, 1), (64, 1), (69, 2),          # 第3
    (67, 2), (None, 2),                 # 第4  留白
    (69, 1), (67, 1), (65, 2),          # 第5
    (64, 2), (None, 2),                 # 第6  留白
    (65, 1), (69, 1), (62, 2),          # 第7
    (67, 2), (None, 2),                 # 第8  留白
]

def piano_note(freq, sr, ring=1.4, vel=1.0):
    """钢琴感单音:多谐波 + 指数衰减 + 短 attack。"""
    n = int(ring * sr)
    t = np.arange(n) / sr
    sig = np.zeros(n, np.float32)
    for k, amp in enumerate([1.0, 0.5, 0.30, 0.16, 0.08], start=1):
        sig += amp * np.exp(-t * (2.6 + 1.1 * k)) * np.sin(2 * np.pi * freq * k * t)
    atk = int(0.006 * sr)
    sig[:atk] *= np.linspace(0, 1, atk)
    return (sig / 2.0 * vel).astype(np.float32)

def add(buf, onset, sig):
    e = min(onset + len(sig), len(buf))
    if onset < len(buf):
        buf[onset:e] += sig[:e - onset]

def pad_chord(notes, dur_s, sr, vel=0.28):
    """柔和持续和弦:慢起音/慢收尾,无敲击瞬态(不会有 piano 的'dong')。"""
    n = int(dur_s * sr); t = np.arange(n) / sr
    sig = np.zeros(n, np.float32)
    for m in notes:
        f = midi_hz(m)
        sig += np.sin(2 * np.pi * f * t) + 0.5 * np.sin(2 * np.pi * f * 0.997 * t) + 0.1 * np.sin(2 * np.pi * 2 * f * t)
    sig /= len(notes)
    atk = int(min(0.35 * sr, n * 0.4)); rel = int(min(0.6 * sr, n * 0.4))
    env = np.ones(n, np.float32)
    env[:atk] = 0.5 - 0.5 * np.cos(np.linspace(0, np.pi, atk))   # 慢起音,无瞬态
    env[-rel:] = 0.5 + 0.5 * np.cos(np.linspace(0, np.pi, rel))  # 慢收尾
    return (sig * env * vel).astype(np.float32)

def ballad_phrase(sr=SR):
    """渲染 8 小节原创乐句,返回 mono buffer。"""
    n = int(8 * BAR * sr) + sr
    buf = np.zeros(n, np.float32)
    # 主旋律(遇 None 休止,只推进时间;整体更弱)
    pos = 0.0
    for m, beats in MELODY:
        if m is not None:
            add(buf, int(pos * sr), piano_note(midi_hz(m), sr, ring=max(1.2, beats * BEAT + 1.0), vel=0.75))
        pos += beats * BEAT
    # 伴奏:柔和持续 pad 和弦,升高一个八度到中音区(保持和声,去掉低频嗡/咚)
    for bar in range(8):
        b0 = bar * BAR
        notes = [mt + 12 for mt in CHORD_TONES[bar]]
        add(buf, int(b0 * sr), pad_chord(notes, BAR + 0.6, sr, vel=0.16))
    return buf[:int(8 * BAR * sr)]

def reverb(x, sr=SR):
    """廉价空间感:两个衰减延迟抽头 + 轻低通。"""
    y = x.copy()
    for delay, g in ((0.11, 0.28), (0.19, 0.18), (0.28, 0.10)):
        d = int(delay * sr)
        tap = np.zeros_like(x)
        tap[d:] = x[:-d] * g
        y += tap
    return lowpass(y, k=14)

def generate_ballad(duration, sr=SR):
    phrase = ballad_phrase(sr)
    reps = int(np.ceil(duration * sr / len(phrase))) + 1
    mono = np.tile(phrase, reps)[:int(duration * sr)]
    mono = reverb(lowpass(mono, k=12), sr)
    peak = np.max(np.abs(mono)) or 1.0
    mono = mono / peak * 0.62
    d = int(0.008 * sr)                       # 立体声宽度
    right = np.concatenate([np.zeros(d, np.float32), mono[:-d]])
    return np.stack([mono, right], axis=1)

# ============ 风格二:氛围 pad(保留) ============
PAD_CHORDS = [[48, 52, 55, 59], [45, 48, 52, 55], [41, 45, 48, 52], [43, 47, 50, 55]]

def _pad_voice(freq, n, sr):
    t = np.arange(n) / sr
    return (np.sin(2 * np.pi * freq * t) + 0.7 * np.sin(2 * np.pi * freq * 0.997 * t)
            + 0.12 * np.sin(2 * np.pi * 2 * freq * t))

def _pad_seg(notes, dur, sr):
    n = int(dur * sr); seg = np.zeros(n, np.float32)
    for m in notes:
        seg += _pad_voice(midi_hz(m), n, sr)
    seg += 0.5 * np.sin(2 * np.pi * midi_hz(notes[0] - 12) * (np.arange(n) / sr))
    seg /= (len(notes) + 1)
    fade = int(min(dur * 0.5, 2.4) * sr)
    env = np.ones(n, np.float32)
    ramp = 0.5 - 0.5 * np.cos(np.linspace(0, np.pi, fade))
    env[:fade] = ramp; env[-fade:] = ramp[::-1]
    return seg * env, fade

def generate_pad(duration, sr=SR, chord_dur=8.0):
    total = int(duration * sr); buf = np.zeros(total + sr, np.float32)
    seg0, fade = _pad_seg(PAD_CHORDS[0], chord_dur, sr); hop = len(seg0) - fade
    pos, i = 0, 0
    while pos < total:
        seg, _ = _pad_seg(PAD_CHORDS[i % 4], chord_dur, sr)
        e = min(pos + len(seg), len(buf)); buf[pos:e] += seg[:e - pos]; pos += hop; i += 1
    mono = lowpass(buf[:total], k=48)
    peak = np.max(np.abs(mono)) or 1.0; mono = mono / peak * 0.62
    d = int(0.009 * sr)
    right = np.concatenate([np.zeros(d, np.float32), mono[:-d]])
    return np.stack([mono, right], axis=1)

def generate(duration, sr=SR, style="ballad"):
    return generate_pad(duration, sr) if style == "pad" else generate_ballad(duration, sr)

def write_wav(path, stereo, sr=SR):
    pcm = (np.clip(stereo, -1, 1) * 32767).astype(np.int16)
    with wave.open(path, "wb") as w:
        w.setnchannels(2); w.setsampwidth(2); w.setframerate(sr)
        w.writeframes(pcm.tobytes())

def main():
    dur = float(sys.argv[1]) if len(sys.argv) > 1 else 60.0
    out = sys.argv[2] if len(sys.argv) > 2 else "bgm.wav"
    style = sys.argv[3] if len(sys.argv) > 3 else "ballad"
    write_wav(out, generate(dur, style=style))
    print(f"BGM[{style}]: {out}  ({dur:.0f}s)")

if __name__ == "__main__":
    main()
GEN_BGM_EOF

# —— md2video.py ——
cat > ~/blog2video/md2video.py <<'MD2VIDEO_EOF'
#!/usr/bin/env python3
"""
幻灯片讲解视频生成器 —— 动画版(本机/edge-tts 配音)
逐帧合成:漂浮粒子背景 + 进度条 + 要点逐条淡入上滑 + 整页 Ken Burns 微推 + 页首尾淡入淡出转场。
帧以 rawvideo 管道直接喂 ffmpeg(不落盘)。

用法:
  python3 md2video.py slides.json out.mp4 --engine edge --voice zh-CN-YunxiNeural --rate 195

slides.json: [{title, subtitle(仅标题页), blocks:[["bullet"|"code"|"note"|"plain", text]], narration}]
"""
import argparse, json, math, os, random, subprocess, sys, tempfile, multiprocessing
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageChops

W, H, FPS = 1920, 1080, 30
BG_TOP, BG_BOT = (13, 17, 23), (8, 11, 16)
PANEL = (22, 27, 34)
ACCENT = (88, 166, 255)
TITLE_C = (240, 246, 252)
BODY_C = (201, 209, 217)
CODE_BG = (1, 4, 9)
# 片尾随机寄语(收尾页底部随机挑一句)
ENDING_QUOTES = [
    "不要对自己没做过的事说没意义",
    "只要今天比昨天好,这就是希望",
]
CODE_C = (126, 231, 135)
NOTE_C = (210, 168, 255)
FOOTER_C = (110, 118, 129)
MARGIN = 110
CONTENT_W = W - 2 * MARGIN

FONT_CJK = "/System/Library/Fonts/Supplemental/STHeiti Medium.ttc"
FONT_MONO = "/System/Library/Fonts/Menlo.ttc"
FONT_CJK2 = "/System/Library/Fonts/Supplemental/Songti.ttc"
def font(p, s): return ImageFont.truetype(p, s)
F_TITLE = font(FONT_CJK, 64); F_SUB = font(FONT_CJK, 38); F_BODY = font(FONT_CJK, 40)
F_CODE = font(FONT_MONO, 32); F_CODECJK = font(FONT_CJK2, 30); F_FOOT = font(FONT_CJK, 26)
# 坑:Menlo 是纯英文等宽,渲染中文会变豆腐块 → 含中文的文字一律用 CJK 字体(F_HUD2/F_EP)
F_HUD = font(FONT_MONO, 26); F_HUD2 = font(FONT_CJK, 24)
F_COVER = font(FONT_CJK, 94); F_COVER2 = font(FONT_CJK, 40); F_EP = font(FONT_CJK, 30)

EDGE_BIN = os.path.expanduser("~/.venv-tts/bin/edge-tts")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import sfx as SFX

# 彩色 emoji(Apple Color Emoji 仅 160px 取样,渲染后缩放)
try:
    EMOJI_FONT = ImageFont.truetype("/System/Library/Fonts/Apple Color Emoji.ttc", 160)
except Exception:
    EMOJI_FONT = None

def emoji_img(ch, size):
    if EMOJI_FONT is None:
        return None
    im = Image.new("RGBA", (180, 180), (0, 0, 0, 0))
    ImageDraw.Draw(im).text((8, 8), ch, font=EMOJI_FONT, embedded_color=True)
    bb = im.getbbox()
    if bb:
        im = im.crop(bb)
    return im.resize((size, size), Image.LANCZOS)

# ---------- 文本折行 ----------
def wrap_w(text, fnt, max_w):
    lines, cur = [], ""
    for ch in text:
        if ch == "\n":
            lines.append(cur); cur = ""; continue
        if fnt.getlength(cur + ch) > max_w:
            lines.append(cur); cur = ch
        else:
            cur += ch
    if cur or not lines:
        lines.append(cur)
    return lines

# ---------- 把每个 block 渲染成一张紧贴内容的 RGBA tile(含 x 偏移) ----------
def block_tile(btype, btext):
    if btype == "code":
        lines = btext.split("\n")
        h = len(lines) * 44 + 40
        tile = Image.new("RGBA", (CONTENT_W, h), (0, 0, 0, 0))
        d = ImageDraw.Draw(tile)
        d.rounded_rectangle([0, 0, CONTENT_W, h], radius=14, fill=CODE_BG + (255,))
        cy = 20
        for cl in lines:
            use = F_CODE if cl.isascii() else F_CODECJK
            col = CODE_C if cl.isascii() else (139, 148, 158)
            d.text((30, cy), cl, font=use, fill=col + (255,)); cy += 44
        return tile, h + 28
    if btype == "note":
        lines = wrap_w(btext, F_BODY, CONTENT_W - 56)
        h = len(lines) * 56
        tile = Image.new("RGBA", (CONTENT_W, h + 8), (0, 0, 0, 0))
        d = ImageDraw.Draw(tile)
        d.text((0, 0), "※", font=F_BODY, fill=NOTE_C + (255,))
        y = 0
        for ln in lines:
            d.text((56, y), ln, font=F_BODY, fill=NOTE_C + (255,)); y += 56
        return tile, h + 8 + 8
    # bullet / plain
    indent = 50 if btype == "bullet" else 0
    lines = wrap_w(btext, F_BODY, CONTENT_W - indent)
    h = len(lines) * 56
    tile = Image.new("RGBA", (CONTENT_W, h), (0, 0, 0, 0))
    d = ImageDraw.Draw(tile)
    if btype == "bullet":
        d.ellipse([6, 24 - 7, 20, 24 + 7], fill=ACCENT + (255,))
    y = 0
    for ln in lines:
        d.text((indent, y), ln, font=F_BODY, fill=BODY_C + (255,)); y += 56
    return tile, h + 10

# ---------- 预计算一页的精灵布局 ----------
def build_layout(slide):
    if slide.get("cover"):
        # 海报式封面:EP 徽标 + 巨标题 + 发光分隔 + 副标题(居中)
        tile = Image.new("RGBA", (W, 560), (0, 0, 0, 0))
        d = ImageDraw.Draw(tile)
        y = 0
        ep = slide.get("ep", "")
        if ep:
            ew = F_EP.getlength(ep)
            d.rectangle([(W - ew) // 2 - 30, y + 6, (W - ew) // 2 - 16, y + 34], fill=ACCENT + (255,))
            d.text(((W - ew) // 2, y), ep, font=F_EP, fill=(150, 205, 245, 255)); y += 64
        for ln in wrap_w(slide["title"], F_COVER, W - 200):
            tw = F_COVER.getlength(ln); d.text(((W - tw) // 2, y), ln, font=F_COVER, fill=TITLE_C + (255,)); y += 112
        y += 10
        d.rectangle([W // 2 - 150, y, W // 2 + 150, y + 5], fill=ACCENT + (255,)); y += 40
        if slide.get("subtitle"):
            for ln in wrap_w(slide["subtitle"], F_COVER2, W - 360):
                sw = F_COVER2.getlength(ln); d.text(((W - sw) // 2, y), ln, font=F_COVER2, fill=BODY_C + (255,)); y += 56
        return [(tile, 0, int(H * 0.30))], True
    if slide.get("ending"):
        # 收尾页:文字偏下(标题+下期预告+CTA),上方留给大爱心(帧循环里画)
        big = Image.new("RGBA", (W, 720), (0, 0, 0, 0))
        d = ImageDraw.Draw(big)
        ty = 0
        for ln in wrap_w(slide["title"], F_TITLE, W - 2 * MARGIN):
            tw = F_TITLE.getlength(ln); d.text(((W - tw) // 2, ty), ln, font=F_TITLE, fill=TITLE_C + (255,)); ty += 82
        sub = slide.get("sub")
        if sub:
            ty += 12
            sw = F_SUB.getlength(sub); d.text(((W - sw) // 2, ty), sub, font=F_SUB, fill=ACCENT + (255,)); ty += 62
        teasers = slide.get("teasers", [])
        if teasers:
            ty += 30
            lab = "↓ 下期预告 ↓"; lw = F_SUB.getlength(lab)
            d.text(((W - lw) // 2, ty), lab, font=F_SUB, fill=ACCENT + (255,)); ty += 60
            for tt in teasers:
                s = "· " + tt; sw = F_SUB.getlength(s)
                d.text(((W - sw) // 2, ty), s, font=F_SUB, fill=BODY_C + (255,)); ty += 52
        cta = slide.get("cta")
        if cta:
            ty += 26; cw = F_BODY.getlength(cta)
            d.text(((W - cw) // 2, ty), cta, font=F_BODY, fill=(255, 120, 150, 255)); ty += 54
        # 片尾随机寄语:从 ENDING_QUOTES 随机挑一句,底部柔和暖金色 + 引号
        quote = slide.get("quote") or (random.choice(ENDING_QUOTES) if ENDING_QUOTES else None)
        if quote:
            ty += 30
            q = "「" + quote + "」"
            qw = F_SUB.getlength(q); d.text(((W - qw) // 2, ty), q, font=F_SUB, fill=(255, 209, 128, 235)); ty += 58
        return [(big, 0, int(H * 0.30))], True   # 爱心已去,内容上移居中
    title_slide = bool(slide.get("subtitle")) and not slide.get("blocks")
    items = []  # (tile, x, y)
    if title_slide:
        tl = wrap_w(slide["title"], F_TITLE, W - 2 * MARGIN)
        big = Image.new("RGBA", (W, len(tl) * 78 + 90), (0, 0, 0, 0))
        d = ImageDraw.Draw(big)
        ty = 0
        for ln in tl:
            tw = F_TITLE.getlength(ln); d.text(((W - tw) // 2, ty), ln, font=F_TITLE, fill=TITLE_C + (255,)); ty += 78
        d.rectangle([W // 2 - 120, ty + 12, W // 2 + 120, ty + 18], fill=ACCENT + (255,))
        ty += 50
        for ln in wrap_w(slide["subtitle"], F_SUB, W - 2 * MARGIN - 200):
            sw = F_SUB.getlength(ln); d.text(((W - sw) // 2, ty), ln, font=F_SUB, fill=BODY_C + (255,)); ty += 54
        items.append((big, 0, H // 2 - big.height // 2))
        return items, True
    # 内容页标题(可带 emoji 图标)
    th = Image.new("RGBA", (CONTENT_W, 110), (0, 0, 0, 0))
    dt = ImageDraw.Draw(th)
    tx = 0
    icon = slide.get("icon")
    if icon:
        em = emoji_img(icon, 66)
        if em is not None:
            th.alpha_composite(em, (0, 4)); tx = 88
    dt.text((tx, 0), slide["title"], font=F_TITLE, fill=ACCENT + (255,))
    dt.rectangle([tx, 90, min(CONTENT_W, tx + 680), 94], fill=(48, 54, 61, 255))
    items.append((th, MARGIN, 104))   # 下移,给顶部 HUD 条(品牌/序号)让出空间
    y = 244
    for btype, btext in slide.get("blocks", []):
        tile, adv = block_tile(btype, btext)
        items.append((tile, MARGIN, y)); y += adv
    return items, False

CYAN = (45, 200, 230)

# ---------- 主题配色:蓝色风(男声·科技蓝) / 粉色风(女声·粉嘟嘟),可被 set_theme 切换 ----------
NEBULA_A = (110, 80, 200)    # 远处星云 1
NEBULA_B = (40, 120, 200)    # 远处星云 2
STREAM_C = (120, 200, 255)   # 科幻流线
STAR_C   = (190, 222, 255)   # 星点
FLOOR_C  = (45, 200, 230)    # Tron 透视地板线
CARD_TINT = (8, 26, 38)      # 全息卡底色叠加
CUTE = False                 # 可爱模式(粉色风开启):飘升 ♥ + 闪烁 ✨

def _lt(c, k=0.4):
    """把颜色往白里提亮 k(0~1),用于发光高光。"""
    return tuple(min(255, int(v + (255 - v) * k)) for v in c)

def set_theme(name):
    """切换整体配色:'blue'(男声·科技蓝,默认) / 'pink'(女声·粉嘟嘟)。重算依赖颜色的预计算资源。"""
    global ACCENT, CYAN, NOTE_C, NEBULA_A, NEBULA_B, STREAM_C, STAR_C, FLOOR_C, CARD_TINT, CUTE, BG, GRAD, SCAN_IMG
    if name == "pink":
        ACCENT = (255, 120, 182); CYAN = (255, 138, 205); NOTE_C = (255, 173, 222)
        NEBULA_A = (210, 90, 190); NEBULA_B = (255, 140, 195)
        STREAM_C = (255, 178, 216); STAR_C = (255, 224, 240)
        FLOOR_C = (255, 130, 200); CARD_TINT = (44, 12, 32)
    else:  # blue
        ACCENT = (88, 166, 255); CYAN = (45, 200, 230); NOTE_C = (210, 168, 255)
        NEBULA_A = (110, 80, 200); NEBULA_B = (40, 120, 200)
        STREAM_C = (120, 200, 255); STAR_C = (190, 222, 255)
        FLOOR_C = (45, 200, 230); CARD_TINT = (8, 26, 38)
    CUTE = (name == "pink")
    BG = make_bg(); GRAD = Image.fromarray(BG, "RGB"); SCAN_IMG = make_scan_img()

# ---------- 静态背景:电影级深邃暗场 + 地平线青光(numpy 一次算好,被运镜) ----------
def make_bg():
    yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
    t = (yy / H)[..., None]
    top = np.array((5, 8, 15), np.float32); bot = np.array((2, 4, 9), np.float32)
    arr = top * (1 - t) + bot * t                       # 接近纯黑的深场
    # 地平线(屏幕 0.60 处)中心青色大辉光 —— 透视地板的"光源"
    HZ = H * 0.60
    r = np.sqrt((xx - W / 2) ** 2 + (yy - HZ) ** 2)
    arr += (np.clip(1 - r / (W * 0.55), 0, 1) ** 2)[..., None] * (np.array(CYAN, np.float32) - arr) * 0.34
    # 顶部一抹蓝
    r2 = np.sqrt((xx - W * 0.5) ** 2 + (yy + H * 0.05) ** 2)
    arr += (np.clip(1 - r2 / (W * 0.6), 0, 1) ** 2)[..., None] * (np.array(ACCENT, np.float32) - arr) * 0.10
    # 远处星云(紫蓝),深空科幻感
    rn = np.sqrt((xx - W * 0.74) ** 2 + (yy - H * 0.26) ** 2)
    arr += (np.clip(1 - rn / (W * 0.46), 0, 1) ** 2)[..., None] * (np.array(NEBULA_A, np.float32) - arr) * 0.20
    rn2 = np.sqrt((xx - W * 0.20) ** 2 + (yy - H * 0.78) ** 2)
    arr += (np.clip(1 - rn2 / (W * 0.40), 0, 1) ** 2)[..., None] * (np.array(NEBULA_B, np.float32) - arr) * 0.14
    # 强暗角
    rv = np.sqrt((xx - W / 2) ** 2 + (yy - H / 2) ** 2) / (0.5 * np.sqrt(W ** 2 + H ** 2))
    arr *= np.clip(1 - 0.52 * rv ** 2, 0, 1)[..., None]
    return np.clip(arr, 0, 255).astype(np.uint8)

GRID_HZ = int(H * 0.60)   # 透视地板地平线

BG_FRAMES = None  # 视频背景:预抽帧的文件列表(--bg-video 时填充)

def make_scrim_png(path):
    """生成压暗蒙版(黑色,alpha 左侧/底部更重),叠在背景上保证文字可读。"""
    yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
    a = 60.0
    a += 95 * np.clip(1 - xx / (W * 0.62), 0, 1)            # 左侧(标题/正文)加重
    a += 70 * np.clip((yy - H * 0.55) / (H * 0.45), 0, 1)   # 底部加重
    a = np.clip(a, 0, 205)
    img = np.zeros((H, W, 4), np.uint8); img[..., 3] = a.astype(np.uint8)
    Image.fromarray(img, "RGBA").save(path)

def extract_bg_video(path, workdir):
    """把背景视频抽帧到 workdir(铺满 WxH + 压暗蒙版 + 略降饱和),返回帧文件列表。"""
    import glob
    scrim = os.path.join(workdir, "scrim.png"); make_scrim_png(scrim)
    outpat = os.path.join(workdir, "bgf_%05d.png")
    vf = (f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},"
          f"fps={FPS},eq=saturation=0.92")
    subprocess.run(["ffmpeg", "-y", "-i", os.path.expanduser(path), "-i", scrim,
                    "-filter_complex", f"[0:v]{vf}[v];[v][1:v]overlay=0:0",
                    outpat], check=True, capture_output=True)
    return sorted(glob.glob(os.path.join(workdir, "bgf_*.png")))

def make_photo_bg(path):
    """用一张图片当背景:铺满(cover)+ 压暗 + 左侧加重暗,保证文字可读。返回 uint8 HxWx3。"""
    im = Image.open(os.path.expanduser(path)).convert("RGB")
    s = max(W / im.width, H / im.height)
    im = im.resize((max(W, int(im.width * s) + 1), max(H, int(im.height * s) + 1)), Image.LANCZOS)
    ox = (im.width - W) // 2; oy = (im.height - H) // 2
    im = im.crop((ox, oy, ox + W, oy + H))
    arr = np.asarray(im).astype(np.float32)
    yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
    # 整体压暗到 ~0.5;左侧(标题/正文区)再压暗;底部稍压
    k = 0.52 - 0.24 * np.clip(1 - xx / (W * 0.66), 0, 1) - 0.10 * np.clip((yy - H * 0.55) / (H * 0.45), 0, 1)
    arr *= np.clip(k, 0.15, 1.0)[..., None]
    return np.clip(arr, 0, 255).astype(np.uint8)

BG = make_bg()

# 深空星场(满屏,密集),配合 bloom 发光
random.seed(7)
STARS = []
for _ in range(110):
    STARS.append(dict(x=random.uniform(0, W), y=random.uniform(0, H * 0.92),
                      r=random.uniform(1.0, 3.6), a=random.uniform(70, 230),
                      sp=random.uniform(0.4, 1.8), ph=random.uniform(0, 6.28),
                      dx=random.uniform(-9, 9), dy=random.uniform(-5, 5)))

# 科幻流线(横向流动的光迹/数据流,带拖尾,铺满全屏,配 bloom 发光)
random.seed(23)
STREAMS = []
for _ in range(26):
    STREAMS.append(dict(y=random.uniform(0, H), sp=random.uniform(240, 680),
                        x0=random.uniform(0, W), length=random.uniform(220, 560),
                        w=random.choice([1, 1, 2]), a=random.uniform(70, 160)))

def draw_streams(d, gt):
    for s in STREAMS:
        hx = (gt * s["sp"] + s["x0"]) % (W + s["length"]) - s["length"]   # 头部向右流动,循环
        seg = 12
        for k in range(seg):
            xa = hx - (k / seg) * s["length"]; xb = hx - ((k + 1) / seg) * s["length"]
            al = int(s["a"] * (1 - k / seg) ** 1.4)                        # 头亮尾淡
            if al > 3:
                d.line([(xa, s["y"]), (xb, s["y"])], fill=(*STREAM_C, al), width=s["w"])

# 全息扫描线(细横线,逐帧上滚;颜色随主题)
def make_scan_img():
    sc = np.zeros((H + 8, W, 4), np.uint8)
    r, g, b = (max(0, c - 80) for c in CYAN)   # 用暗一点的主题色
    sc[::3, :, 0] = r; sc[::3, :, 1] = g; sc[::3, :, 2] = b; sc[::3, :, 3] = 14
    return Image.fromarray(sc, "RGBA")
SCAN_IMG = make_scan_img()

def scanlines(frame, fidx):
    off = fidx % 8
    return Image.alpha_composite(frame, SCAN_IMG.crop((0, off, W, off + H)))

# ---------- 爱心(收尾页:弹出+心跳+漂浮小心心) ----------
HEART_UNIT = []
for _i in range(101):
    _tt = _i / 100 * 2 * math.pi
    _x = 16 * math.sin(_tt) ** 3
    _y = 13 * math.cos(_tt) - 5 * math.cos(2 * _tt) - 2 * math.cos(3 * _tt) - math.cos(4 * _tt)
    HEART_UNIT.append((_x / 16.0, _y / 16.0))

def draw_heart(d, cx, cy, size, color, alpha):
    if size <= 1 or alpha <= 2:
        return
    pts = [(cx + ux * size, cy - uy * size) for (ux, uy) in HEART_UNIT]
    d.polygon(pts, fill=color + (int(alpha),))

def heartbeat(t):
    tt = t % 1.1                       # 双跳心律(咚-哒)
    return math.exp(-(tt / 0.07) ** 2) + 0.65 * math.exp(-((tt - 0.16) / 0.07) ** 2)

def ease_out_back(p):
    p = max(0.0, min(1.0, p)); c1 = 1.70158; c3 = c1 + 1
    return 1 + c3 * (p - 1) ** 3 + c1 * (p - 1) ** 2

random.seed(13)
MINI = []
for _ in range(14):
    MINI.append(dict(x=random.uniform(W * 0.18, W * 0.82), delay=random.uniform(0.3, 3.2),
                     size=random.uniform(15, 30), rise=random.uniform(190, 340),
                     life=random.uniform(2.0, 3.0), sway=random.uniform(20, 55),
                     hue=random.choice([(255, 90, 120), (255, 120, 150), (255, 70, 95)])))

GRAD = Image.fromarray(BG, "RGB")  # 静态渐变底图

def _progress(d, prog):
    d.rectangle([0, H - 5, W, H], fill=(26, 32, 42, 170))
    fw = int(W * prog)
    d.rectangle([0, H - 5, fw, H], fill=ACCENT + (235,))
    d.ellipse([fw - 7, H - 11, fw + 7, H + 3], fill=(*_lt(ACCENT, 0.5), 240))

# ---------- 可爱模式装饰(粉色风):飘升的 ♥ + 闪烁 ✨ ----------
random.seed(41)
CUTE_HEARTS = [dict(x=random.uniform(0.05, 0.95) * W, period=random.uniform(7, 12),
                    phase=random.uniform(0, 1), size=random.uniform(11, 22),
                    sway=random.uniform(20, 60), swayf=random.uniform(0.3, 0.8),
                    a=random.uniform(45, 95)) for _ in range(10)]
random.seed(57)
CUTE_SPARKLES = [dict(x=random.uniform(0, W), y=random.uniform(0, H * 0.95),
                      period=random.uniform(1.6, 3.2), phase=random.uniform(0, 1),
                      size=random.uniform(5, 12), a=random.uniform(120, 210)) for _ in range(16)]

def draw_sparkle(d, cx, cy, r, color, al):
    """四角星闪光 ✨。"""
    pts = []
    for ang, rad in [(0, r), (45, r * 0.32), (90, r), (135, r * 0.32),
                     (180, r), (225, r * 0.32), (270, r), (315, r * 0.32)]:
        aa = math.radians(ang); pts.append((cx + rad * math.cos(aa), cy + rad * math.sin(aa)))
    d.polygon(pts, fill=(*color, al))

def draw_cute(d, gt):
    """飘升爱心 + 闪烁星光,只在 CUTE(粉色)模式画;在 bloom 之前画好,会自带发光晕。"""
    if not CUTE:
        return
    for hp in CUTE_HEARTS:
        t = ((gt / hp["period"]) + hp["phase"]) % 1.0
        y = H * 1.04 - (H * 1.12) * t                       # 从底部升到顶部循环
        x = hp["x"] + hp["sway"] * math.sin(gt * hp["swayf"] + hp["phase"] * 6.28)
        al = int(hp["a"] * math.sin(t * math.pi))           # 两端淡入淡出
        if al > 4:
            draw_heart(d, x, y, hp["size"], (255, 150, 190), al)
    for sp in CUTE_SPARKLES:
        tw = 0.5 + 0.5 * math.sin((gt / sp["period"] + sp["phase"]) * 6.28318)
        al = int(sp["a"] * tw * tw)                          # 一闪一闪
        if al > 6:
            draw_sparkle(d, sp["x"], sp["y"], sp["size"] * (0.6 + 0.4 * tw), (255, 240, 250), al)

def overlay(global_t, prog, env, light=False):
    """Tron 透视地板 + 星空粒子 + 发光进度条(原生分辨率,配合 bloom 发光)。"""
    ov = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    d = ImageDraw.Draw(ov)
    if light:
        _progress(d, prog); return ov
    HZ = GRID_HZ
    # 1) 透视地板:竖线(向地平线消失点汇聚)
    for i in range(-10, 11):
        bx = W / 2 + i * (W / 15.0)
        d.line([(bx, H), (W / 2.0, HZ)], fill=(*FLOOR_C, 52), width=1)
    # 2) 横线(向观众滚动,近处更亮更粗)
    M = 16
    for k in range(M):
        dp = ((k / M) + (global_t * 0.05)) % 1.0
        y = HZ + (H - HZ) * (dp * dp)
        a = int(85 * dp)
        if a > 3:
            d.line([(0, y), (W, y)], fill=(*FLOOR_C, a), width=2 if dp > 0.6 else 1)
    # 3) 地平线亮带
    d.line([(0, HZ), (W, HZ)], fill=(*_lt(CYAN, 0.45), 60), width=2)
    # 3.5) 科幻流线铺满
    draw_streams(d, global_t)
    # 4) 星空粒子(闪烁,bloom 让它们发光)
    for s in STARS:
        x = s["x"] + s["dx"] * math.sin(global_t * 0.15 + s["ph"])
        y = s["y"] + s["dy"] * math.cos(global_t * 0.12 + s["ph"])
        a = int(s["a"] * (0.45 + 0.55 * math.sin(global_t * s["sp"] + s["ph"])))
        r = s["r"]
        if a > 4:
            d.ellipse([x - r, y - r, x + r, y + r], fill=(*STAR_C, a))
    draw_cute(d, global_t)
    _progress(d, prog)
    return ov

def bloom(frame):
    """电影级辉光:降采样模糊后 screen 叠加,让亮线/星点发光晕。"""
    rgb = frame.convert("RGB")
    small = rgb.resize((W // 3, H // 3)).filter(ImageFilter.GaussianBlur(4)).resize((W, H))
    return Image.blend(rgb, ImageChops.screen(rgb, small), 0.55).convert("RGBA")

# 电影颗粒:预生成几张噪点,循环叠加(低成本)
random.seed(99)
_GRAIN = []
for _ in range(5):
    nz = (np.random.RandomState(random.randint(0, 9999)).rand(H // 2, W // 2) * 255).astype(np.uint8)
    g = Image.fromarray(nz, "L").resize((W, H))
    rgba = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    rgba.putalpha(g.point(lambda v: int(v * 0.05)))   # 很淡
    _GRAIN.append(Image.merge("RGBA", (g, g, g, rgba.split()[3])))

def add_grain(frame, fidx):
    return Image.alpha_composite(frame, _GRAIN[0])   # 静态颗粒:纹理保留但不每帧变,利于压缩(否则文件暴涨)

def glass_panel(frame, box, gt=0.0, radius=18):
    """全息读屏卡:青色半透底 + 内部扫描线 + 发光青边 + 四角刻线 + 微闪(星战全息感)。"""
    x0, y0, x1, y1 = box
    x0 = max(0, x0); y0 = max(0, y0); x1 = min(W, x1); y1 = min(H, y1)
    w, h = x1 - x0, y1 - y0
    if w < 8 or h < 8:
        return frame
    flick = 0.85 + 0.15 * (0.5 + 0.5 * math.sin(gt * 7.0)) * (0.6 + 0.4 * math.sin(gt * 23.0))
    # 底:背景压暗 + 青色调
    region = frame.crop((x0, y0, x1, y1)).convert("RGB")
    rb = region.resize((max(1, w // 5), max(1, h // 5))).filter(ImageFilter.GaussianBlur(3)).resize((w, h))
    rb = Image.fromarray((np.asarray(rb).astype(np.float32) * 0.34 + np.array(CARD_TINT)).clip(0, 255).astype(np.uint8))
    mask = Image.new("L", (w, h), 0)
    ImageDraw.Draw(mask).rounded_rectangle([0, 0, w - 1, h - 1], radius=radius, fill=255)
    frame.paste(rb, (x0, y0), mask)   # 卡内不再画扫描线(避免文字把横线截成"虚线")
    d = ImageDraw.Draw(frame)
    ba = int(190 * flick)
    d.rounded_rectangle([x0, y0, x1 - 1, y1 - 1], radius=radius, outline=(*_lt(CYAN, 0.2), ba), width=2)
    # 四角刻线(HUD)
    t = 26
    for cx, cy, sx, sy in [(x0, y0, 1, 1), (x1, y0, -1, 1), (x0, y1, 1, -1), (x1, y1, -1, -1)]:
        d.line([(cx, cy), (cx + sx * t, cy)], fill=(*_lt(CYAN, 0.45), ba), width=2)
        d.line([(cx, cy), (cx, cy + sy * t)], fill=(*_lt(CYAN, 0.45), ba), width=2)
    return frame

def hud_chrome(idx, total, is_cover=False):
    """HUD 科技边框:四角括号 + 顶部居中品牌标签 + 右上章节序号(bloom 之后画,锐利)。"""
    ov = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    d = ImageDraw.Draw(ov)
    L, m, col = 46, 40, (*_lt(ACCENT, 0.25), 150)
    for cx, cy, sx, sy in [(m, m, 1, 1), (W - m, m, -1, 1), (m, H - m, 1, -1), (W - m, H - m, -1, -1)]:
        d.line([(cx, cy), (cx + sx * L, cy)], fill=col, width=2)
        d.line([(cx, cy), (cx, cy + sy * L)], fill=col, width=2)
    # 顶部居中品牌标签(accent 方块 + 文字),下移到顶部括号下方
    brand = "SRE OPS NOTES"
    by = m + 44
    bw = F_HUD.getlength(brand)
    bx = (W - bw) // 2
    d.rectangle([bx - 26, by + 4, bx - 14, by + 16], fill=ACCENT + (230,))
    d.text((bx, by), brand, font=F_HUD, fill=(*_lt(ACCENT, 0.4), 220))
    if not is_cover:
        s = f"{idx + 1:02d} / {total:02d}"
        d.text((W - m - 18 - F_HUD.getlength(s), m + 12), s, font=F_HUD, fill=(*_lt(ACCENT, 0.4), 210))
    return ov

# ---------- Ken Burns 微推:只作用于背景渐变(低频,无重采样抖动) ----------
def ken_burns_bg(t, dur, zoom_in):
    f = (t / dur) if dur else 0.0
    z = 1.0 + 0.045 * (f if zoom_in else (1 - f))           # 缓慢缩放
    nw, nh = int(W * z), int(H * z)
    big = GRAD.resize((nw, nh), Image.BICUBIC)
    panx = 0.18 + 0.30 * (f if zoom_in else (1 - f))        # 单向匀速平移,不来回摆
    px = int((nw - W) * panx); py = int((nh - H) * 0.5)
    return big.crop((px, py, px + W, py + H)).convert("RGBA")

# ---------- 渲染一页为 mp4(帧管道喂 ffmpeg) ----------
def render_clip(slide, idx, audio, out_mp4, audio_dur, global_off, total_dur, tail=0.9, sfx_on=True, nslides=1, crf=32, preset="medium"):
    dur = audio_dur + tail
    n = int(round(dur * FPS))
    items, is_title = build_layout(slide)
    is_ending = bool(slide.get("ending"))
    is_cover = bool(slide.get("cover"))
    # 内容页:整体在画面内垂直居中(短内容不头重脚轻),磁贴范围 → 全息卡边界
    panel = None; voff = 0
    if not is_title and items:
        ct = min(y for (it, x, y) in items)
        cb = max(y + it.height for (it, x, y) in items)
        voff = max(0, int((115 + (H - 70)) / 2 - (ct + cb) / 2))   # 居中于 HUD 条与底部之间
        panel = (MARGIN - 50, 86 + voff, W - MARGIN + 50, cb + 40 + voff)

    # 计算每个非标题元素的出现时刻(标题=item0 立即;blocks 按高度权重铺开)
    appear = [0.0] * len(items)
    if not is_title and len(items) > 1:
        weights = [items[i][0].height for i in range(1, len(items))]
        tot = sum(weights) or 1
        win = max(dur * 0.72, dur - 1.6)
        acc = 0.0
        for k, w in enumerate(weights):
            appear[k + 1] = 0.45 + win * (acc / tot)
            acc += w
    FADE = 0.45

    # 音效轨(切页嗖/要点啵/结尾撒花),与人声 amix
    sfx_wav = None
    if sfx_on:
        appears = [appear[i] for i in range(1, len(items)) if appear[i] > 0]
        sfx_wav = tempfile.mktemp(suffix=".wav")
        SFX.write_wav(sfx_wav, SFX.build(dur, appears, is_title, is_ending))
    # 不用 -shortest(否则音频一结束就停,多喂的尾帧会 broken pipe);视频喂满 n 帧定义总时长。
    cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pixel_format", "rgb24",
           "-video_size", f"{W}x{H}", "-framerate", str(FPS), "-i", "pipe:0", "-i", audio]
    if sfx_wav:
        cmd += ["-i", sfx_wav, "-filter_complex",
                "[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[a]", "-map", "0:v", "-map", "[a]"]
    else:
        cmd += ["-af", "apad"]
    cmd += ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p",
            "-c:a", "aac", "-b:a", "192k", "-t", f"{dur:.3f}", out_mp4]
    pr = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)

    zoom_in = (idx % 2 == 0)
    for fidx in range(n):
        t = fidx / FPS
        gt = global_off + t
        prog = min(1.0, gt / total_dur) if total_dur else 0
        # 页首/尾整体淡入淡出;收尾页不做末尾淡出,定格在「感谢观看」直到结束(否则字淡光只剩空背景)
        fade_out = 1.0 if is_ending else (dur - t) / 0.5
        env = max(0.0, min(1.0, min(t / 0.4, fade_out, 1.0)))
        # 1) 背景:视频帧(循环) 或 渐变运镜
        if BG_FRAMES:
            bf = BG_FRAMES[int(round(gt * FPS)) % len(BG_FRAMES)]
            frame = Image.open(bf).convert("RGBA")
            frame = Image.alpha_composite(frame, overlay(gt, prog, env, light=True))
        else:
            frame = ken_burns_bg(t, dur, zoom_in)
            frame = Image.alpha_composite(frame, overlay(gt, prog, env))
            frame = bloom(frame)   # 电影级辉光(文字在 bloom 之后贴,保持清晰)
            frame = add_grain(frame, fidx)
            frame = scanlines(frame, fidx)   # 全息扫描线
        # 2) 高级感层:全息读屏卡(内容页) + HUD 边框(bloom 之后画,锐利)
        if panel:
            glass_panel(frame, panel, gt)
        frame = Image.alpha_composite(frame, hud_chrome(idx, nslides, is_cover))
        # 3) 文字精灵:原生分辨率、像素对齐贴上,纹丝不动且清晰
        for i, (tile, x, y) in enumerate(items):
            f = 1.0 if (is_title or i == 0) else max(0.0, min(1.0, (t - appear[i]) / FADE))
            # 上滑用 easeOut,更顺
            ef = 1 - (1 - f) ** 2
            f *= env
            if f <= 0.01:
                continue
            yoff = (voff if not is_title else 0) + (int(round((1 - ef) * 24)) if (not is_title and i > 0) else 0)
            if f >= 0.99:
                frame.alpha_composite(tile, (x, y + yoff))
            else:
                a = tile.split()[3].point(lambda v: int(v * f))
                t2 = tile.copy(); t2.putalpha(a)
                frame.alpha_composite(t2, (x, y + yoff))
        # (收尾页爱心已按用户要求去掉)
        try:
            pr.stdin.write(frame.convert("RGB").tobytes())
        except BrokenPipeError:
            break
    pr.stdin.close(); pr.wait()
    return dur


def _layout_timing(slide, dur):
    """提取一页的布局与时间轴(items/is_title/.../appear),render_seg 与 sfx 共用。"""
    items, is_title = build_layout(slide)
    is_ending = bool(slide.get("ending")); is_cover = bool(slide.get("cover"))
    panel = None; voff = 0
    if not is_title and items:
        ct = min(y for (it, x, y) in items); cb = max(y + it.height for (it, x, y) in items)
        voff = max(0, int((115 + (H - 70)) / 2 - (ct + cb) / 2))
        panel = (MARGIN - 50, 86 + voff, W - MARGIN + 50, cb + 40 + voff)
    appear = [0.0] * len(items)
    if not is_title and len(items) > 1:
        weights = [items[i][0].height for i in range(1, len(items))]
        tot = sum(weights) or 1; win = max(dur * 0.72, dur - 1.6); acc = 0.0
        for k, w in enumerate(weights):
            appear[k + 1] = 0.45 + win * (acc / tot); acc += w
    return items, is_title, is_ending, is_cover, panel, voff, appear


def render_seg(slide, idx, f_start, f_end, n, dur, global_off, total_dur, nslides, crf, preset, seg_out):
    """渲染 clip(idx) 的第 [f_start,f_end) 帧到纯视频 seg_out(无音频),供帧段级并行;
       按真实帧号 fidx 算时间轴,段间动画无缝衔接。"""
    items, is_title, is_ending, is_cover, panel, voff, appear = _layout_timing(slide, dur)
    FADE = 0.45
    cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pixel_format", "rgb24",
           "-video_size", f"{W}x{H}", "-framerate", str(FPS), "-i", "pipe:0",
           "-an", "-c:v", "libx264", "-preset", preset, "-crf", str(crf),
           "-pix_fmt", "yuv420p", seg_out]
    pr = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)
    zoom_in = (idx % 2 == 0)
    for fidx in range(f_start, f_end):
        t = fidx / FPS; gt = global_off + t
        prog = min(1.0, gt / total_dur) if total_dur else 0
        fade_out = 1.0 if is_ending else (dur - t) / 0.5
        env = max(0.0, min(1.0, min(t / 0.4, fade_out, 1.0)))
        if BG_FRAMES:
            bf = BG_FRAMES[int(round(gt * FPS)) % len(BG_FRAMES)]
            frame = Image.open(bf).convert("RGBA")
            frame = Image.alpha_composite(frame, overlay(gt, prog, env, light=True))
        else:
            frame = ken_burns_bg(t, dur, zoom_in)
            frame = Image.alpha_composite(frame, overlay(gt, prog, env))
            frame = bloom(frame); frame = add_grain(frame, fidx); frame = scanlines(frame, fidx)
        if panel:
            glass_panel(frame, panel, gt)
        frame = Image.alpha_composite(frame, hud_chrome(idx, nslides, is_cover))
        for i, (tile, x, y) in enumerate(items):
            f = 1.0 if (is_title or i == 0) else max(0.0, min(1.0, (t - appear[i]) / FADE))
            ef = 1 - (1 - f) ** 2; f *= env
            if f <= 0.01:
                continue
            yoff = (voff if not is_title else 0) + (int(round((1 - ef) * 24)) if (not is_title and i > 0) else 0)
            if f >= 0.99:
                frame.alpha_composite(tile, (x, y + yoff))
            else:
                a = tile.split()[3].point(lambda v: int(v * f)); t2 = tile.copy(); t2.putalpha(a)
                frame.alpha_composite(t2, (x, y + yoff))
        try:
            pr.stdin.write(frame.convert("RGB").tobytes())
        except BrokenPipeError:
            break
    pr.stdin.close(); pr.wait()
    return seg_out

# ---------- TTS ----------
def tts(text, voice, rate, out_path, engine):
    if engine == "edge":
        pct = int((rate - 185) / 185 * 100); sign = "+" if pct >= 0 else "-"
        subprocess.run([EDGE_BIN, "--voice", voice, "--rate", f"{sign}{abs(pct)}%",
                        "--text", text, "--write-media", out_path], check=True)
    else:
        subprocess.run(["say", "-v", voice, "-r", str(rate), "-o", out_path, text], check=True)

def adur(p):
    o = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
                        "-of", "default=nw=1:nk=1", p], capture_output=True, text=True)
    return float(o.stdout.strip())

def make_vertical(src, dst, side_keep=1.0, crf=28):
    """从横版(1920x1080)派生竖版手机版(1080x1920),不重渲。
    模糊填充:背景=放大铺满竖屏+重模糊+压暗;前景=原画两侧各裁((1-side_keep)/2)后按宽1080放大居中。
    ⚠️ side_keep 默认 1.0=不裁:slide 正文(plain行)贴着卡片左右边,任何侧裁都会把边缘字吃掉
    (实测 0.86 裁7% 就把"node_exporter"的n、"换成"裁没了)。要内容更大只能牺牲文字完整,别轻易调小。"""
    filt = (
        "[0:v]split=2[bg][fg];"
        "[bg]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
        "boxblur=30:2,eq=brightness=-0.28:saturation=0.9[bgb];"
        f"[fg]crop=iw*{side_keep}:ih,scale=1080:-2[fgs];"
        "[bgb][fgs]overlay=(W-w)/2:(H-h)/2,format=yuv420p[v]")
    subprocess.run(["ffmpeg", "-y", "-i", src, "-filter_complex", filt,
                    "-map", "[v]", "-map", "0:a", "-c:v", "libx264", "-preset", "medium",
                    "-crf", str(crf), "-c:a", "copy", dst], check=True, capture_output=True)
    return dst

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("slides"); ap.add_argument("out")
    ap.add_argument("--voice", default="zh-CN-YunxiNeural")
    ap.add_argument("--rate", type=int, default=195)
    ap.add_argument("--engine", choices=["say", "edge"], default="edge")
    ap.add_argument("--music", default="none", help="none | auto(本机合成) | <音频文件路径>")
    ap.add_argument("--music-vol", type=float, default=0.11, help="BGM 音量(0~1),默认 0.11 垫在人声下")
    ap.add_argument("--no-sfx", action="store_true", help="关闭音效(切页嗖/要点啵/结尾撒花)")
    ap.add_argument("--bg-image", default="", help="用一张图片当背景(铺满+压暗保证文字可读),替代科技背景")
    ap.add_argument("--bg-video", default="", help="用一段视频当动态背景(铺满+压暗),替代科技背景")
    ap.add_argument("--crf", type=int, default=32, help="x264 CRF,越大文件越小越糊;32≈最小,26≈高质量")
    ap.add_argument("--preset", default="medium",
                    help="x264 preset:medium(默认)/slow/slower/veryslow。越慢同画质文件越小(并行渲时慢的代价被多核吸收)")
    ap.add_argument("--parallel-clips", type=int, default=0,
                    help="并行渲染的 clip 进程数(0=自动取 min(页数, CPU核数));单版内部多 clip 同时渲,压渲染墙钟")
    ap.add_argument("--no-cover", action="store_true", help="不导出封面 PNG(默认会导出 <输出名>_封面.png)")
    ap.add_argument("--both", action="store_true",
                    help="同时出桌面版(横16:9)+手机版(竖9:16抖音)。输出 桌面版-<名>.mp4 与 手机版-<名>.mp4(标签加在文件名最前)")
    ap.add_argument("--theme", default="auto", choices=["auto", "blue", "pink"],
                    help="配色:auto(按音色自动:女声=粉嘟嘟 / 男声=科技蓝)| blue | pink")
    a = ap.parse_args()
    # 主题配色:auto 时女声(晓晓/晓伊)用粉、其余用蓝
    theme = a.theme
    if theme == "auto":
        theme = "pink" if any(k in a.voice for k in ("Xiaoxiao", "Xiaoyi")) else "blue"
    set_theme(theme)
    print(f" 配色: {theme}")
    work = tempfile.mkdtemp(prefix="md2v_")
    if a.bg_image:
        global GRAD
        GRAD = Image.fromarray(make_photo_bg(a.bg_image), "RGB")
    if a.bg_video:
        global BG_FRAMES
        BG_FRAMES = extract_bg_video(a.bg_video, work)
        print(f"背景视频抽帧: {len(BG_FRAMES)} 帧")
    ext = "mp3" if a.engine == "edge" else "aiff"
    slides = json.load(open(a.slides, encoding="utf-8"))

    # 1) 先全部配音(线程池并行:edge-tts 是联网 IO,8 页同时请求省掉串行等待),拿到时长以铺时间轴
    from concurrent.futures import ThreadPoolExecutor
    def _tts_one(i_s):
        i, s = i_s
        ap_ = os.path.join(work, f"a{i:02d}.{ext}")
        tts(s["narration"], a.voice, a.rate, ap_, a.engine)
        return ap_, adur(ap_)
    with ThreadPoolExecutor(max_workers=min(len(slides), 8)) as _ex:
        _res = list(_ex.map(_tts_one, list(enumerate(slides))))
    auds = [r[0] for r in _res]; durs = [r[1] for r in _res]
    tail = 0.9
    clip_durs = [d + tail for d in durs]
    total = sum(clip_durs)
    offs = [sum(clip_durs[:i]) for i in range(len(slides))]

    # 2) 帧段级并行渲染:把每页的帧切成多段,所有段进一个进程池统一调度,8 核始终满载、
    #    负载均衡,单版墙钟≈总帧数÷核数(逼近物理极限),不再受最长 clip 拖累。
    #    fork 上下文:子进程继承父进程已 set_theme 的全局配色/字体/BG_FRAMES,无需重设。
    nproc = a.parallel_clips or max(2, (os.cpu_count() or 4) - 2)  # 默认留 2 核给交互/系统,边渲边用不卡;--parallel-clips 可覆盖
    ns = [int(round((durs[i] + tail) * FPS)) for i in range(len(slides))]
    SEG = 150  # 每段帧数(~5s);切细让负载均衡(段数 >> 核数)
    seg_tasks = []; clip_segs = [[] for _ in slides]
    for i, s in enumerate(slides):
        for j, fs in enumerate(range(0, ns[i], SEG)):
            fe = min(fs + SEG, ns[i])
            sp = os.path.join(work, f"seg_{i:02d}_{j:03d}.mp4")
            seg_tasks.append((s, i, fs, fe, ns[i], durs[i] + tail, offs[i], total,
                              len(slides), a.crf, a.preset, sp))
            clip_segs[i].append(sp)
    print(f"⚙️  帧段级并行: {len(slides)} clip → {len(seg_tasks)} 段 × {nproc} 进程  (preset={a.preset}, crf={a.crf})")
    if nproc <= 1:
        for t in seg_tasks:
            render_seg(*t)
    else:
        ctx = multiprocessing.get_context("fork")
        with ctx.Pool(nproc) as pool:
            pool.starmap(render_seg, seg_tasks)
    # 每页:concat 视频段(纯视频,-c copy 不重编) → 混入人声+sfx → 完整 clip
    clips = []
    for i, s in enumerate(slides):
        vlist = os.path.join(work, f"vlist_{i:02d}.txt")
        open(vlist, "w").write("".join(f"file '{sp}'\n" for sp in clip_segs[i]))
        cvid = os.path.join(work, f"c{i:02d}_v.mp4")
        subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", vlist,
                        "-c", "copy", cvid], check=True, capture_output=True)
        dur = durs[i] + tail
        sfx_wav = None
        if not a.no_sfx:
            _items, _ist, _isend, _iscov, _p, _v, appear = _layout_timing(s, dur)
            appears = [appear[k] for k in range(1, len(appear)) if appear[k] > 0]
            sfx_wav = tempfile.mktemp(suffix=".wav")
            SFX.write_wav(sfx_wav, SFX.build(dur, appears, _ist, _isend))
        cmp4 = os.path.join(work, f"c{i:02d}.mp4")
        mix = ["ffmpeg", "-y", "-i", cvid, "-i", auds[i]]
        if sfx_wav:
            mix += ["-i", sfx_wav, "-filter_complex",
                    "[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[a]", "-map", "0:v", "-map", "[a]"]
        else:
            mix += ["-map", "0:v", "-map", "1:a", "-af", "apad"]
        mix += ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-t", f"{dur:.3f}", cmp4]
        subprocess.run(mix, check=True, capture_output=True)
        clips.append(cmp4)
        print(f"[{i+1}/{len(slides)}] {s['title'][:28]}  ({clip_durs[i]:.1f}s)")

    # 3) 拼接(无 BGM 直接出 a.out;有 BGM 先拼到临时文件再混音)
    listf = os.path.join(work, "list.txt")
    open(listf, "w").write("".join(f"file '{c}'\n" for c in clips))
    concat_out = a.out if a.music == "none" else os.path.join(work, "concat.mp4")
    subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listf,
                    "-c", "copy", concat_out], check=True, capture_output=True)

    # 4) 背景音乐混音
    if a.music != "none":
        T = adur(concat_out)
        if a.music == "auto":
            # 优先用 skill 目录里固化的 BGM(默认 bgm_video.mp3 → 退回 bgm_ballad.mp3 → 现场合成)
            sd = os.path.dirname(os.path.abspath(__file__))
            bundled = next((os.path.join(sd, f) for f in ("bgm_video.mp3", "bgm_ballad.mp3")
                            if os.path.exists(os.path.join(sd, f))), None)
            if bundled:
                music_in = bundled
            else:
                sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
                import gen_bgm
                music_in = os.path.join(work, "bgm.wav")
                gen_bgm.write_wav(music_in, gen_bgm.generate(T + 1.0))
        else:
            music_in = os.path.expanduser(a.music)
        fo = max(0.1, T - 3.5)
        filt = (f"[1:a]volume={a.music_vol},afade=t=in:st=0:d=3,"
                f"afade=t=out:st={fo:.2f}:d=3.5[m];"
                f"[0:a][m]amix=inputs=2:duration=first:normalize=0[a]")
        subprocess.run(["ffmpeg", "-y", "-i", concat_out, "-stream_loop", "-1", "-i", music_in,
                        "-filter_complex", filt, "-map", "0:v", "-map", "[a]",
                        "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-t", f"{T:.3f}", a.out],
                       check=True, capture_output=True)
    # 5) 封面图:若首页是封面,自动从成片抽一帧导出 PNG(上传当缩略图);--no-cover 关
    if slides and slides[0].get("cover") and not a.no_cover:
        cover_png = os.path.splitext(a.out)[0] + "_封面.png"
        subprocess.run(["ffmpeg", "-y", "-ss", f"{clip_durs[0] * 0.6:.2f}", "-i", a.out,
                        "-frames:v", "1", cover_png], check=True, capture_output=True)
        print(f"️ 封面图: {cover_png}")
    # 6) --both:在桌面版之外,额外派生手机版(竖屏)。桌面版加 -桌面版 后缀,新增 -手机版
    if a.both:
        d, fn = os.path.split(a.out)
        desk = os.path.join(d, f"桌面版-{fn}"); mob = os.path.join(d, f"手机版-{fn}")
        os.replace(a.out, desk)
        print(f"️  桌面版: {desk}")
        make_vertical(desk, mob, crf=max(26, a.crf - 4))
        print(f" 手机版: {mob}  (1080x1920 竖屏)")
    final = (desk if a.both else a.out)
    print(f"\n✅ 完成: {final}  ({adur(final):.0f}s, {len(slides)}页)  BGM={a.music}\n   临时: {work}")

if __name__ == "__main__":
    main()
MD2VIDEO_EOF

echo "✅ 脚本和示例文章已写入 ~/blog2video/"

跑完确认一下,应该列出 5 个文件:

ls ~/blog2video
# 预期看到:article.md  gen_bgm.py  make_slides.py  md2video.py  sfx.py

看到这 5 个文件就说明装好了。各是干啥的:

文件 作用
md2video.py 渲染引擎(核心):把幻灯片画成画面、配上音、合成 mp4。出片就是跑它。
make_slides.py 把一篇 Markdown 文章切成幻灯片清单 slides.json
sfx.py 合成切页/要点/撒花的音效,被引擎自动调用,你不用直接碰。
gen_bgm.py 合成零版权背景音乐,被引擎自动调用,你不用直接碰。
article.md 示例文章(就是这篇教程的精简版,拿它当演示)。先用它跑通流程,之后换成你自己写的文章即可。

简单说:你平时只跟 make_slides.pymd2video.py 和你自己的 .md 打交道;sfx.pygen_bgm.py 是引擎的"零件",放着别动就行。


第二部分 · 做出你的第一条视频

东西齐了,正式开做,就三小步:切幻灯片 → 出预览 → 出完整版。

2.1 把文章切成幻灯片

进到刚建好的文件夹,把文章自动切成一页页幻灯片:

cd ~/blog2video
python3 make_slides.py article.md slides.json --title "用 Claude 把博客做成讲解视频" --subtitle "对 Claude 说一句话就行"

跑完会生成一个 slides.json。它就是"每页显示什么、每页念什么"的清单,长这样(节选):

[
  { "cover": true, "title": "用 Claude 把博客做成讲解视频", "subtitle": "对 Claude 说一句话就行",
    "narration": "把写好的一篇博客交给 Claude,它就能帮你做成带配音的讲解视频。" },
  { "title": "它在背后做了什么",
    "blocks": [["plain", "画面由程序一帧一帧画出来,配音用免费的神经语音合成。"]],
    "narration": "画面由程序一帧一帧画出来,配音用免费的神经语音合成,最后合成 mp4。" }
]
  • title 是这页标题,blocks 是页面上显示的内容,narration这页的配音文本
  • 第一次先别改它,直接往下走,先把整条流程跑通看到效果;旁白怎么写得好,放到后面专门讲。

2.2 先出一个 15 秒预览

别急着出完整视频。先出 15 秒预览,看一眼风格满不满意——这是最重要的习惯,原因下面解释。复制示例文章的前两页做预览:

cd ~/blog2video
python3 -c "import json;d=json.load(open('slides.json'));json.dump(d[:2],open('preview.json','w'),ensure_ascii=False,indent=2)"
python3 md2video.py preview.json ~/Downloads/预览.mp4 --music auto --no-cover

跑完去「下载」文件夹双击 预览.mp4 播放。看看科幻背景、配音、配乐、字幕布局喜不喜欢。

为什么必须先预览? 渲染是一帧一帧画出来的,一条几分钟的视频要画七八千帧、跑好几分钟。

直接出完整版,万一风格不喜欢,这几分钟就白等,改一点又得重跑。先 15 秒定风格,再出完整版,省的全是你的时间。


2.3 出完整视频

预览满意,出完整版:

cd ~/blog2video
python3 md2video.py slides.json ~/Downloads/我的第一条视频.mp4 --music auto --no-cover

等它把每页画完(终端会打印 [1/2] ... 这样的进度),完成后去「下载」文件夹双击 我的第一条视频.mp4。 这就是你的第一条讲解视频。

到这里,你已经会用它出片了。下面是锦上添花和进阶玩法,按需要看。


第三部分 · 把成片做得更好(可选)

前两部分已经能出片。这一部分都是可选提升项,按需做、不必全做

3.1 改旁白:别让它像念稿(质量命门)

成片好不好听,九成取决于 slides.json 里的 narrationmake_slides.py 自动生成的旁白是从正文硬切来的,照读会很像念课文。用任意文本编辑器打开 slides.json,把每页的 narration 改顺:

  • 像给同事讲解一样,口语、第一人称、结论先行,别照搬书面句子;
  • 绝不逐字念代码、念 Markdown——代码只在画面上展示就行,旁白里别念;
  • 数字和符号写成口语,否则 TTS 读得别扭:<8 写「小于 8」、15m 写「十五分钟」、exported_namespace 写「exported namespace」。

blocks(画面显示)和 narration(配音)可以不一样:画面给要点,旁白给讲解。改完重新跑 2.2、2.3 两步即可。

它到底怎么把一篇文章变成视频的?同一份 slides.json,一边用 Pillow 把每页画成画面帧,一边用 edge-tts 把旁白念成音轨,最后 ffmpeg 合到一起:

一篇文章如何变成 mp4:切页 → 画面帧 + 配音 → 合成

3.2 验收:抽帧检查一下

你没法把每一帧都看一遍,但抽几张关键帧用眼睛核一下,能避免低级翻车:

cd ~/Downloads
ffmpeg -y -ss 30 -i 我的第一条视频.mp4 -frames:v 1 /tmp/mid.png        # 中间一帧
ffmpeg -y -sseof -0.1 -i 我的第一条视频.mp4 -update 1 /tmp/last.png    # 真·最后一帧
ffprobe 我的第一条视频.mp4                                            # 看有没有声音、时长对不对

双击 /tmp/mid.png/tmp/last.png,核三点:

  • 中文不是「□」豆腐块、要点都在;
  • 结尾稳稳停在「感谢观看」整卡(不是淡成空背景);
  • ffprobe 输出里有 Audio: 流 = 有声音。

配音里英文术语读得自不自然,得你亲耳听一下(这个我替不了你)。

3.3 换音色 / 配乐 / 手机竖版

在 2.3「出完整视频」的命令后面加参数即可:

想要的效果 加的参数
换配音音色 --voice zh-CN-YunjianNeural(下表选一个)
语速快一点 --rate 195(185≈正常)
不要背景音乐 --music none
同时出手机竖版(抖音 9:16) --both
文件更小 --crf 36(默认 32,越大越小越糊)
换配色风格 --theme blue(科技蓝)/ --theme pink(粉嘟嘟);默认 auto 按音色自动选

六个普通话音色:zh-CN-YunxiNeural(云希男·默认)、YunjianNeural(云健男·解说)、YunyangNeural(云扬男·播音)、YunxiaNeural(云夏男)、XiaoxiaoNeural(晓晓女)、XiaoyiNeural(晓伊女),前面都要带 zh-CN-

配色会跟着音色自动变:女声(晓晓/晓伊)出粉色风(背景还会飘 ♥ + 闪 ✨)、男声出科技蓝;也可以用 --theme blue|pink 手动指定。

版权提醒:--music auto 用的是脚本本机合成的原创零版权 BGM,可以放心发公开平台。别自己换成有版权的商业歌曲当 BGM,会被消音/下架。


第四部分 · 让 Claude 一句话替你出片(进阶)

上面都是你自己敲命令。如果你装了 Claude Code,可以把这套脚本包成一个 skill,以后只要在对话里说「把这篇做成视频」,Claude 就照着流程替你跑。以下机制都依据 Claude Code 官方文档。

4.1 装 Claude Code 并登录

需要 Claude 付费订阅或 Anthropic API 账号:

curl -fsSL https://claude.ai/install.sh | bash
claude        # 启动,首次会弹浏览器让你登录

4.2 建 skill 目录,把脚本放进去

mkdir -p ~/.claude/skills/blog-to-video
cp ~/blog2video/*.py ~/.claude/skills/blog-to-video/

4.3 写一份说明书 SKILL.md

~/.claude/skills/blog-to-video/ 下新建 SKILL.md,Claude 靠它知道怎么用(官方说 frontmatter 里只有 description 推荐必写)。把下面这段存进去:

---
name: blog-to-video
description: 把一篇 Markdown / 博客文章做成「讲解幻灯片 + 中文配音」的本机渲染视频。当用户说「把这篇文章做成视频」「博客转视频」时使用。
---

# 博客文章 → 讲解视频

把一篇 Markdown 变成配音讲解 mp4。画面 Pillow 逐帧渲染、配音 edge-tts、合成 ffmpeg。脚本都在本 skill 目录,用 ${CLAUDE_SKILL_DIR} 引用。

##  铁律:先出 ~15s 预览再出完整版
任何时候出片,先挑封面+1 页做约 15 秒预览给用户确认风格,点头后再出完整版(完整版要几分钟,改细节会全废)。

## 步骤
1. 出幻灯片初稿:python3 ${CLAUDE_SKILL_DIR}/make_slides.py article.md slides.json --title "标题" --subtitle "副标题"
2. 精编 slides.json 的 narration:口语化、结论先行、别念代码、数字符号写成口语(<8→小于8)。
3. 出 15s 预览:挑封面+1 页存 preview.json,python3 ${CLAUDE_SKILL_DIR}/md2video.py preview.json ~/Downloads/预览.mp4 --music auto --no-cover,给用户确认。
4. 出完整版:python3 ${CLAUDE_SKILL_DIR}/md2video.py slides.json 输出.mp4 --voice zh-CN-YunxiNeural --music auto --no-cover
5. 抽帧验证:中段帧 + 真·最后一帧(ffmpeg -sseof -0.1)+ ffprobe 查音频流;提示用户试听配音。

## 参数
--voice(6 音色)/ --rate(185≈正常)/ --music(none|auto 本机零版权|路径)/ --theme(auto/blue/pink,女声自动粉、男声自动蓝)/ --crf(默认32)/ --both(手机竖版)/ --no-cover。

## 坑
含中文的文字必须用中文字体(否则豆腐块,脚本已分流);收尾页不做末尾淡出(否则结尾停在空背景);旁白别照读代码。

4.4 用起来

在 Claude Code 对话里打 /blog-to-video,或直接说「把 ~/blog2video/article.md 做成讲解视频」。Claude 会读 SKILL.md、照步骤调脚本,并按铁律先给你 15 秒预览。

官方说:skill 目录里可以放脚本(.py 无限制),用 ${CLAUDE_SKILL_DIR} 引路径;改 SKILL.md 即时生效不用重启(全新建的 skills 目录才需重启一次)。

说明书写得越细,Claude 跑得越稳——把你的偏好和坑都写进去。这就是 skill 的价值:把踩过的坑固化成可复用流程。


附录 A · 新手常见坑

  1. 中文变「□」豆腐块:用了纯英文字体渲染中文。脚本里已分流(中文走黑体/宋体),自己改脚本时别踩回去。
  2. 配音像机器人:别用 mac 自带的 say,它是老式合成。脚本默认用 edge-tts 神经音色,自然很多(代价是配音要联网)。
  3. ffmpeg: command not found:1.2 没装好 ffmpeg,重跑 brew install ffmpeg
  4. edge-tts 报错 / 没声音:八成没装进 ~/.venv-tts 或网络问题,重跑 1.2 的第 4 行和那条验证命令。
  5. 视频几百兆:富动态背景天生码率高,几十兆正常;嫌大加 --crf 36
  6. 结尾停在空背景:老问题,脚本已修(收尾页不淡出);验收时抽真·最后一帧确认即可。

附录 B · 快速参考

# 一次性装环境
brew install ffmpeg
pip3 install Pillow numpy
python3 -m venv ~/.venv-tts && ~/.venv-tts/bin/pip install edge-tts

# 出片三步(在 ~/blog2video 里)
python3 make_slides.py article.md slides.json --title "标题" --subtitle "副标题"   # 切幻灯片(然后改 slides.json 的 narration)
python3 -c "import json;d=json.load(open('slides.json'));json.dump(d[:2],open('preview.json','w'),ensure_ascii=False,indent=2)"
python3 md2video.py preview.json ~/Downloads/预览.mp4 --music auto --no-cover      # 先出 15s 预览
python3 md2video.py slides.json ~/Downloads/成片.mp4 --voice zh-CN-YunxiNeural --music auto --no-cover   # 出完整版

# 抽帧验收
ffmpeg -y -sseof -0.1 -i 成片.mp4 -update 1 /tmp/last.png && ffprobe 成片.mp4

几条铁律:全程本机不接文生视频 API;先出 15s 预览再出完整版;旁白手写口语版别念代码;公开发布用 --music auto 零版权 BGM;验收必抽最后一帧 + 查音频流。

1.3 那段安装命令里已经内嵌了全部脚本源码(make_slides.py / md2video.py / sfx.py / gen_bgm.py),想读或单独改某个脚本,直接看第二步那个代码块,或用编辑器打开 ~/blog2video/ 里对应文件。

posted @ 2026-06-10 14:42  Hello_worlds  阅读(29)  评论(0)    收藏  举报