Install
openclaw skills install doc-to-video将 Markdown 技术文档自动转换成带配音旁白的专业视频。使用 edge-tts 生成自然人声、Remotion 渲染视觉场景、FFmpeg 合并音视频,输出 1920×1080 全高清视频。适用场景:项目文档视频化、教程制作、知识分享。
openclaw skills install doc-to-videoSkill 名称:doc-to-video 适用版本:OpenClaw / QClaw 技能类型:文档 → 视频自动化 输出格式:1920×1080 MP4,H.264 视频 + AAC 音频
将 Markdown 技术文档一键转换成带自然人声旁白的专业视频。 从内容分析、旁白编写、配音生成、视觉渲染,到音视频合并,全流程自动化。
本 Skill 已在三个真实项目中验证:
| 视频 | 时长 | 场景数 | 文件大小 |
|---|---|---|---|
| Docker Registry 使用指南 | ~153s | 9个 | ~3.2MB |
| Docker Registry 搭建记录 | ~207s | 16个 | ~5.1MB |
| Solidity Nomad 多签教程 | ~210s | 11个 | ~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
mkdir my-video-project && cd my-video-project
mkdir -p src audio out
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())
python3 generate_audio.py
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
# 生成文件列表
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
// 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)} />;
};
src/index.tsximport 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}
/>
));
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(该场景前累计秒数 / 音频总秒数 × 实际渲染总帧数)
Remotion 内置 <Audio> 组件依赖 React,在多场景场景下不稳定(报错 #130)。
FFmpeg atempo 无损加速,可精确控制时长,音质可控。
#0b1d3a → #1a3a6b)或代码风格(#0d1117)// 动画进度 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×1080 | 16:9 全高清 |
| 目标时长 | 180–210 秒 | 3–3.5分钟 |
| 加速比 | 1.3–2.0× | 过大影响音质 |
| atempo 级联 | 两级相乘≈目标加速比 | 每级不超过 2.0 |
| 每段旁白 | 100–300 字 | 对应场景内容 |
| 动画时长 | 30–40 帧 | ~1–1.3秒 |
| 音频码率 | 128k AAC | 清晰度与体积平衡 |
| 推荐 voice | zh-CN-XiaoxiaoNeural | 女声,自然流畅 |
原因:帧边界基于估算帧数,而非实际渲染帧数。
解决:
ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames -of csv=p=0 out/video.mp4index.tsx 的 durationInFrames 和场景组件的 F[]原因:入口文件没有用 Composition API 注册。
解决:
import { Composition, registerRoot } from "remotion";
registerRoot(() => (
<Composition id="UniqueId" component={Scene}
durationInFrames={6295} fps={30} width={1920} height={1080} />
));
原因:原视频有静音音频轨道,-shortest 保留了原轨道。
解决: 必须先用 -an 去掉原音:
ffmpeg -i video.mp4 -an -c:v copy noaudio.mp4
ffmpeg -i noaudio.mp4 -i audio.m4a -shortest output.mp4
原因:单级 atempo 超过 2.0。
解决: 两级级联,例如 3.5x:atempo=1.87,atempo=1.87
备选: macOS 系统语音 say -v Tingting -r 175 "旁白内容"
转 MP3:ffmpeg -i audio.aiff -codec:a libmp3lame -qscale:a 2 audio.mp3
(注意:音质远不如 edge-tts)
SKILL.md 包含完整的 frontmatter(name, description, author, version, tags 等)doc-to-video将 Markdown 技术文档自动转换成带配音旁白的专业视频Video & Media 或 Automationmarkdown, video, edge-tts, remotion, ffmpeg, tutorialskill-doc-to-video/
├── SKILL.md ← 必须
├── generate_audio.py ← 推荐一起打包
└── preview.png ← 可选封面图
如果 ClawHub 支持 CLI 发布:
# 方式一:直接推送目录
npx clawhub publish ./skill-doc-to-video
# 方式二:登录后推送
clawhub login
clawhub publish --name doc-to-video --dir ./skill-doc-to-video
本 Skill 并非一步到位,而是通过三次迭代逐步完善:
思路:Remotion 渲染视频 → 用 FFmpeg 合并配音 问题:音频与视频不同步,因为帧边界计算有误
思路:渲染一次视频,用 ffprobe 确认实际帧数,再反推边界 问题:确认帧数是对的,但渲染出来的帧数与预期仍有偏差
核心发现:
durationInFrames 是参考值,实际帧数由内容决定<Audio> 组件在 Remotion 中不稳定,改用 FFmpeg 直接嵌入音频最终流程(固化在本 Skill 中):
Markdown → 旁白 → edge-tts → 拼接加速
→ 渲染确认帧数 → ffprobe 实测 → 精确帧边界
→ 重渲染 → FFmpeg 嵌入音频 → 完成
三个验证项目:
docker-registry-guide-final.mp4 — 9场景,153s ✅deploy-docker-registry-final.mp4 — 16场景,207s ✅solidtidy-final.mp4 — 11场景,210s,音视频完美同步 ✅MIT License — 可自由使用、修改、分发。