将 Markdown 技术文档自动转换成带配音旁白的专业视频

Other

将 Markdown 技术文档自动转换成带配音旁白的专业视频。使用 edge-tts 生成自然人声、Remotion 渲染视觉场景、FFmpeg 合并音视频,输出 1920×1080 全高清视频。适用场景:项目文档视频化、教程制作、知识分享。

Install

openclaw skills install doc-to-video

🎬 Doc to Video:Markdown 文档转专业视频

Skill 名称:doc-to-video 适用版本:OpenClaw / QClaw 技能类型:文档 → 视频自动化 输出格式:1920×1080 MP4,H.264 视频 + AAC 音频

将 Markdown 技术文档一键转换成带自然人声旁白的专业视频。 从内容分析、旁白编写、配音生成、视觉渲染,到音视频合并,全流程自动化。


📌 效果预览

本 Skill 已在三个真实项目中验证:

视频时长场景数文件大小
Docker Registry 使用指南~153s9个~3.2MB
Docker Registry 搭建记录~207s16个~5.1MB
Solidity Nomad 多签教程~210s11个~6.2MB

🔧 核心技术栈

Markdown 文档
     │
     ▼
┌─────────────────┐
│  edge-tts        │  ← 中文自然人声(Tingting/XiaoxiaoNeural)
│  Python 生成配音 │
└────────┬────────┘
         │ .m4a 音频文件
         ▼
┌─────────────────┐
│  FFmpeg atempo   │  ← 加速配音匹配目标时长
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Remotion        │  ← React 场景组件,TypeScript
│  视觉场景渲染     │     帧率 30fps,分辨率 1920×1080
└────────┬────────┘
         │ MP4 视频(无声)
         ▼
┌─────────────────┐
│  FFmpeg 合并     │  ← 去原音 + 嵌入配音
└────────┬────────┘
         │
         ▼
   带配音的 MP4 视频 ✅

📦 安装

方式一:一键安装(推荐)

skillhub install doc-to-video

SkillHub 自动安装 Python 依赖(edge-tts)和 Node 依赖(Remotion)。

方式二:手动安装

# 1. 安装 Python 依赖
pip3 install edge-tts

# 2. 安装 FFmpeg
brew install ffmpeg        # macOS
apt install ffmpeg         # Ubuntu/Debian

# 3. 确认 Remotion 已安装在工作区
ls /Users/mac/.qclaw-oversea/workspace/node_modules/.bin/remotion

🚀 快速开始

Step 1:创建工作目录

mkdir my-video-project && cd my-video-project
mkdir -p src audio out

Step 2:编写 generate_audio.py

#!/usr/bin/env python3
"""生成各场景配音(edge-tts XiaoxiaoNeural)"""
import asyncio, edge_tts, os

SCENES = [
    ("00_title",    "欢迎观看本教程。本节介绍主要内容..."),
    ("01_chapter1", "第一章,首先介绍背景知识..."),
    ("02_chapter2", "第二章,讲解核心概念..."),
    # 更多场景...
]

VOICE = "zh-CN-XiaoxiaoNeural"
os.makedirs("audio", exist_ok=True)

async def gen(scene_id: str, text: str):
    m4a = f"audio/{scene_id}.m4a"
    if os.path.exists(m4a):
        print(f"  [skip] {scene_id}")
        return
    print(f"  → {scene_id}...")
    await edge_tts.Communicate(text, VOICE).save(m4a)
    print(f"    done")

async def main():
    await asyncio.gather(*[gen(sid, txt) for sid, txt in SCENES])
    print("\nAll done!")

asyncio.run(main())

Step 3:生成配音

python3 generate_audio.py

Step 4:测量各段音频时长

for f in audio/*.m4a; do
  dur=$(ffprobe -v error -show_entries format=duration \
    -of default=noprint_wrappers=1:nokey=1 "$f")
  echo "$f: ${dur}s"
done

Step 5:拼接 + 加速音频

# 生成文件列表
cat > audio/file_list.txt << 'EOF'
file 'audio/00_title.m4a'
file 'audio/01_chapter1.m4a'
file 'audio/02_chapter2.m4a'
# ...所有文件
EOF

# 拼接
ffmpeg -y -f concat -safe 0 -i audio/file_list.txt \
  -codec:a libmp3lame -qscale:a 2 audio/combined_raw.mp3

# 加速(示例:原始 360s → 目标 210s,加速比 1.714)
# 两级 atempo = sqrt(1.714) ≈ 1.31
ffmpeg -y -i audio/combined_raw.mp3 \
  -filter:a "atempo=1.31,atempo=1.31" \
  -codec:a aac -b:a 128k audio/combined_final.m4a

Step 6:编写 Remotion 场景组件

// src/Scene.tsx
import React from "react";
import { useCurrentFrame } from "remotion";

function prog(t: number, s: number, d: number): number {
  return Math.min(1, Math.max(0, (t - s) / d));
}

// 精确帧边界(先渲染一次确认实际帧数后填入)
const F = [0, 266, 1096, 1780, 2730, 3545, 4093, 4610, 5215, 5715, 6130];

export const Scene: React.FC = () => {
  const f = useCurrentFrame();
  if (f < F[1])  return <CoverScene p={prog(f, 0, 40)} />;
  if (f < F[2])  return <Chapter1Scene p={prog(f, F[1], 40)} />;
  // ... 更多场景
  return <EndScene p={prog(f, F[F.length-1], 40)} />;
};

Step 7:入口文件 src/index.tsx

import React from "react";
import { Composition, registerRoot } from "remotion";
import { Scene } from "./Scene";

registerRoot(() => (
  <Composition
    id="MyVideo"
    component={Scene}
    durationInFrames={6295}   // 先填估算值,后续更正
    fps={30}
    width={1920}
    height={1080}
  />
));

Step 8:渲染 + 合并

cd /path/to/workspace

# 第一次渲染:确认实际帧数
./node_modules/.bin/remotion render \
  my-project/src/index.tsx MyVideo \
  out/temp.mp4

# ffprobe 确认实际帧数
ffprobe -v error -select_streams v:0 \
  -show_entries stream=nb_frames -of csv=p=0 out/temp.mp4
# → 假设输出 6295,用此值更新 F[] 和 durationInFrames

# 重新渲染(用精确帧数)
./node_modules/.bin/remotion render \
  my-project/src/index.tsx MyVideo \
  out/final_video.mp4

# 合并音视频
ffmpeg -y -i out/final_video.mp4 -an -c:v copy /tmp/noaudio.mp4
ffmpeg -y -i /tmp/noaudio.mp4 -i audio/combined_final.m4a \
  -c:v copy -c:a aac -b:a 128k -shortest \
  out/final_with_audio.mp4

# 验证
ffprobe -v error -show_streams out/final_with_audio.mp4 \
  | grep -E "codec_type|duration"

🔑 核心经验:音视频同步的坑与解法

❌ 错误做法(会导致不同步)

估算时长 → 计算帧边界 → 渲染 → 合并音频
          ↑ 用的是估算帧数,实际渲染帧数可能不同

Remotion 渲染的实际帧数不一定等于 durationInFrames 设置值! 因为 Remotion 按内容自动决定帧数,CSS 动画时长也会影响。

✅ 正确做法(两步确认法)

估算时长 → 渲染一次视频 → ffprobe 确认实际帧数
         ↓ 用实际帧数重新计算帧边界
       更新 F[] + durationInFrames → 重新渲染 → 合并

帧边界计算公式:

某场景开始帧 = round(该场景前累计秒数 / 音频总秒数 × 实际渲染总帧数)

为什么音频用 FFmpeg atempo 而不是 Remotion 内置?

Remotion 内置 <Audio> 组件依赖 React,在多场景场景下不稳定(报错 #130)。 FFmpeg atempo 无损加速,可精确控制时长,音质可控。


🎨 场景组件设计规范

布局原则

  • 背景:深色渐变(#0b1d3a → #1a3a6b)或代码风格(#0d1117
  • 字体:标题 40–52px,内容 15–17px,等宽 13–14px
  • 间距:水平留白 80–100px,垂直居中

动画原则

// 动画进度 0→1(约 1–1.5 秒)
function prog(t: number, s: number, d: number): number {
  return Math.min(1, Math.max(0, (t - s) / d));
}
function ease(t: number) { return t * t; }

// 示例:渐入 + 上浮
<div style={{
  opacity: ease(p),                          // 0→1
  transform: `translateY(${(1-ease(p))*30}px)`, // 下→上 30px
}}>

推荐视觉组件

组件场景特点
CodeBlock代码展示黑色背景,蓝色文字,等宽字体
StepItem步骤流程彩色编号圆圈 + 文字说明
ProblemCard问题排查红色标题,原因+解决布局
BulletItem要点列表图标 + 内容
Tag章节标签圆角胶囊 + 光晕效果
VideoScene场景容器渐变背景 + 相对定位

📂 项目结构示例

my-video-project/
├── generate_audio.py      # 配音生成脚本
├── src/
│   ├── index.tsx          # Remotion 入口
│   └── Scene.tsx          # 场景组件
├── audio/
│   ├── 00_title.m4a       # 各场景配音
│   ├── 01_chapter1.m4a
│   ├── ...
│   ├── combined_final.m4a # 拼接加速后完整音频
│   └── file_list.txt      # 拼接文件列表
└── out/
    ├── temp.mp4            # 首次渲染(确认帧数用)
    └── final_with_audio.mp4 # 最终输出

⚙️ 参数参考

参数推荐值说明
帧率30 fps标准视频帧率
分辨率1920×108016:9 全高清
目标时长180–210 秒3–3.5分钟
加速比1.3–2.0×过大影响音质
atempo 级联两级相乘≈目标加速比每级不超过 2.0
每段旁白100–300 字对应场景内容
动画时长30–40 帧~1–1.3秒
音频码率128k AAC清晰度与体积平衡
推荐 voicezh-CN-XiaoxiaoNeural女声,自然流畅

❓ 常见问题

Q1:音频比视频快(或慢)——最常见问题

原因:帧边界基于估算帧数,而非实际渲染帧数。

解决:

  1. ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames -of csv=p=0 out/video.mp4
  2. 用实际帧数重新计算所有帧边界
  3. 更新 index.tsxdurationInFrames 和场景组件的 F[]
  4. 重新渲染并合并

Q2:Remotion 渲染报错 "useCurrentFrame() can only be called..."

原因:入口文件没有用 Composition API 注册。

解决:

import { Composition, registerRoot } from "remotion";
registerRoot(() => (
  <Composition id="UniqueId" component={Scene}
    durationInFrames={6295} fps={30} width={1920} height={1080} />
));

Q3:FFmpeg 合并后音频只有几 KB

原因:原视频有静音音频轨道,-shortest 保留了原轨道。

解决: 必须先用 -an 去掉原音:

ffmpeg -i video.mp4 -an -c:v copy noaudio.mp4
ffmpeg -i noaudio.mp4 -i audio.m4a -shortest output.mp4

Q4:atempo 加速后人声变调

原因:单级 atempo 超过 2.0。

解决: 两级级联,例如 3.5x:atempo=1.87,atempo=1.87

Q5:edge-tts 无网络

备选: macOS 系统语音 say -v Tingting -r 175 "旁白内容" 转 MP3:ffmpeg -i audio.aiff -codec:a libmp3lame -qscale:a 2 audio.mp3 (注意:音质远不如 edge-tts)


📤 发布到 SkillHub / ClawHub

发布前准备

  1. 确保 SKILL.md 包含完整的 frontmatter(name, description, author, version, tags 等)
  2. Skill 目录结构清晰,文件命名规范
  3. 准备好封面图(可选,512×512 PNG)

SkillHub(推荐)

访问 https://clawhub.ai

  1. 注册/登录 ClawHub 账号
  2. 点击 「Publish Skill」「Submit」
  3. 填写信息:
    • Skill Name: doc-to-video
    • Description: 将 Markdown 技术文档自动转换成带配音旁白的专业视频
    • Category: Video & MediaAutomation
    • Tags: markdown, video, edge-tts, remotion, ffmpeg, tutorial
    • Author: 你的昵称或机构名
  4. 上传文件:
    skill-doc-to-video/
    ├── SKILL.md              ← 必须
    ├── generate_audio.py     ← 推荐一起打包
    └── preview.png           ← 可选封面图
    
  5. 点击提交,等待审核通过

ClawHub(原生)

如果 ClawHub 支持 CLI 发布:

# 方式一:直接推送目录
npx clawhub publish ./skill-doc-to-video

# 方式二:登录后推送
clawhub login
clawhub publish --name doc-to-video --dir ./skill-doc-to-video

🧠 Skill 开发过程记录

本 Skill 并非一步到位,而是通过三次迭代逐步完善:

第一版:基础流程

思路:Remotion 渲染视频 → 用 FFmpeg 合并配音 问题:音频与视频不同步,因为帧边界计算有误

第二版:加入精确帧计算

思路:渲染一次视频,用 ffprobe 确认实际帧数,再反推边界 问题:确认帧数是对的,但渲染出来的帧数与预期仍有偏差

第三版(最终):两步确认法 + FFmpeg 嵌入音频

核心发现

  • Remotion durationInFrames 是参考值,实际帧数由内容决定
  • 必须先渲染 → ffprobe 确认 → 再算边界 → 更新代码 → 重渲染
  • <Audio> 组件在 Remotion 中不稳定,改用 FFmpeg 直接嵌入音频

最终流程(固化在本 Skill 中):

Markdown → 旁白 → edge-tts → 拼接加速
  → 渲染确认帧数 → ffprobe 实测 → 精确帧边界
  → 重渲染 → FFmpeg 嵌入音频 → 完成

三个验证项目:

  1. docker-registry-guide-final.mp4 — 9场景,153s ✅
  2. deploy-docker-registry-final.mp4 — 16场景,207s ✅
  3. solidtidy-final.mp4 — 11场景,210s,音视频完美同步 ✅

📄 许可

MIT License — 可自由使用、修改、分发。