1. 为什么我要用代码画一条白板视频
第一次看到那种手绘白板动画,我脑子里蹦出来的念头不是“这特效真酷”,而是“这玩意儿要是用剪辑软件一帧一帧摆,得累死”。后来发现市面上确实有现成的白板视频工具,但用下来总有几个绕不过去的坎:模板固定、笔迹生硬、导出带水印,想改一个细节得在时间轴上拖半天。于是我就琢磨,能不能干脆用代码把每一笔都算出来,让程序去画,而不是我去画。
这个项目的核心思路就一句话:用 rough.js 生成手绘风格的线条,用 Playwright 驱动浏览器逐帧截图,最后用 ffmpeg 把图片序列压成视频。整条链路里没有一步是手工操作,改一个参数就能重新生成一版,特别适合需要批量产出或者频繁调整的场景。它解决的问题很明确——把“白板动画”从手工劳动变成可编程的流水线。
适合谁来参考呢?如果你写过一点 JavaScript,对命令行不陌生,又恰好有做教学视频、产品演示、知识科普的需求,那这套方案基本可以照抄。哪怕你之前没碰过 ffmpeg,只要跟着步骤走,也能跑通。我下面会把每个环节的坑和技巧都摊开讲,尽量让你少走弯路。
2. 整体方案设计与技术选型拆解
2.1 为什么是“代码画”而不是“软件做”
传统白板视频的制作路径大概是这样的:找素材、导入剪辑软件、给每个元素加“手绘”入场动画、调整笔迹速度、导出。这套流程做一条两条还行,一旦要改文案或者换配色,基本等于重做。而且软件自带的手绘效果往往是预设的,笔触方向、线条抖动都是固定的,看多了会觉得千篇一律。
用代码画的好处在于,每一笔的起点、终点、抖动幅度、绘制速度都是参数。你可以让线条从左上往右下画,也可以反过来;可以让笔迹抖得厉害一点显得随意,也可以平滑一些显得工整。更重要的是,这些参数可以抽成配置,换一套配置就是另一种风格。我实测下来,同一段文案用不同参数生成,观感差异非常明显,这在剪辑软件里是很难低成本实现的。
另一个关键点是可版本控制。代码和配置都在 Git 里,哪天想回退到上一版风格,直接 checkout 就行。素材、脚本、输出视频三者分离,团队协作时也不会出现“最终版_final_真的最终版.mp4”这种混乱。
2.2 三个核心工具的分工与选型理由
这套方案里三个工具各司其职,缺一不可,我逐个说说为什么选它们。
rough.js负责“画”。它是一个轻量级的 Canvas 绘图库,专门用来生成手绘风格的图形。你给它一个矩形或者一条路径,它会自动加上抖动和重绘效果,看起来就像人手画的。选它而不是自己写抖动算法,是因为它已经处理好了随机种子、笔触重叠、填充纹理这些细节,开箱即用。而且它支持固定随机种子,意味着同样的参数每次生成的笔迹完全一致,这对视频制作很重要——不然每帧抖动都不一样,画面会乱跳。
Playwright负责“截”。它本来是个浏览器自动化测试工具,但在这里被我用成了“渲染引擎”。思路是:用 HTML 和 Canvas 把每一帧画出来,然后用 Playwright 打开这个页面,按时间轴逐帧截图。选它而不是 Puppeteer,主要是因为它对多浏览器的支持更稳,截图 API 也更干净,而且安装依赖的过程相对省心。实测在 Windows 和 macOS 上表现一致,没有出现字体渲染差异导致笔迹偏移的问题。
ffmpeg负责“合”。截图出来是一堆 PNG,得把它们按顺序压成视频。ffmpeg 是这方面的事实标准,命令行参数灵活,支持各种编码格式和帧率控制。选它没有太多悬念,关键是搞清楚几个参数怎么配,这个我后面会详细讲。
2.3 数据流与时间轴设计
整条链路的数据流是这样的:配置对象 → 逐帧渲染函数 → Playwright 截图 → PNG 序列 → ffmpeg 合成 → MP4。
这里有个设计决策值得说一下:我是按“帧”来驱动动画的,而不是按“时间”。具体来说,我会先确定视频的总帧数,比如 30fps、时长 10 秒,那就是 300 帧。然后每一帧对应一个进度值 t,从 0 到 1。渲染函数根据 t 计算出当前应该画到哪一步,比如某条线画了 60%,某个文字透明度是 0.3。这样设计的好处是动画进度和帧率解耦,想改成 60fps 只需要调整总帧数,动画逻辑不用动。
时间轴的设计上,我把每个元素的入场时间、持续时间、退场时间都做成配置项。比如标题在第 0 到 1 秒淡入,第 1 到 3 秒保持,第 3 到 4 秒淡出。这些时间点最终会被换算成帧号,渲染函数根据当前帧号判断每个元素的状态。这种“声明式”的写法比在代码里写一堆 if-else 要清晰得多,改起来也方便。
3. 核心细节解析与实操要点
3.1 rough.js 的手绘参数怎么调才自然
rough.js 的 API 看着简单,但参数调不好,画出来的东西要么太规整像机器画的,要么抖得太厉害像帕金森。我踩了几轮坑之后,总结出一套比较稳的参数范围。
首先是roughness,控制线条的粗糙程度。默认值是 1,实测下来1.5 到 2.5 之间比较像人手画。低于 1 会显得太光滑,高于 3 就抖得离谱了。然后是bowing,控制线条的弯曲程度,默认 1,我一般调到1.2 左右,让直线稍微带一点弧度,看起来更自然。
还有一个容易被忽略的参数是seed。rough.js 用种子来控制随机数,同一个种子生成的笔迹完全一致。这在视频制作里是必须的,否则每帧的抖动都不一样,画面会像在抽搐。我的做法是给每个元素分配一个固定的种子,比如标题用 42,第一条线用 43,以此类推。这样每次重新生成视频,笔迹都是稳定的。
const rc = rough.canvas(canvas); rc.line(x1, y1, x2, y2, { roughness: 1.8, bowing: 1.2, seed: 42, stroke: '#2c3e50', strokeWidth: 2 });注意:rough.js 的
seed参数在不同版本里行为可能略有差异,建议锁定版本号,避免升级后笔迹全变。
3.2 Playwright 截图的关键配置与性能优化
用 Playwright 截图,最容易踩的坑是截图时机。如果页面还没渲染完就截,会得到空白或者半成品。我的做法是在页面里暴露一个全局函数,比如window.renderFrame(frameIndex),Playwright 调用它之后,等一个requestAnimationFrame再截图。这样能确保 Canvas 已经画完了。
另一个坑是截图速度。默认情况下,每次截图都会走一遍完整的浏览器渲染流程,300 帧可能要跑好几分钟。我试过几个优化手段:一是把deviceScaleFactor设成 1,不要用高清屏的 2 倍缩放,不然图片尺寸翻倍,合成时也慢;二是关闭不必要的浏览器特性,比如--disable-gpu、--no-sandbox;三是用page.screenshot()的时候指定clip区域,只截 Canvas 部分,减少图像处理开销。
const browser = await chromium.launch({ args: ['--disable-gpu', '--no-sandbox'] }); const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 }); await page.goto('file://' + path.resolve('render.html')); for (let i = 0; i < totalFrames; i++) { await page.evaluate((idx) => window.renderFrame(idx), i); await page.evaluate(() => new Promise(r => requestAnimationFrame(r))); await page.screenshot({ path: `frames/frame_${String(i).padStart(5, '0')}.png` }); }实测这套配置下,300 帧大概 40 秒左右能截完,比默认配置快了一倍多。
3.3 ffmpeg 合成参数的计算与选择
截图出来是 PNG 序列,命名要规范,比如frame_00000.png、frame_00001.png,这样 ffmpeg 才能按顺序读取。合成命令的核心参数是帧率和编码器。
帧率方面,白板视频一般 24fps 或 30fps 就够了,太高没必要,文件还大。我一般用 30fps,动作看起来比较顺滑。编码器选libx264,兼容性好,压缩率也不错。关键参数是-crf,控制画质,范围 0 到 51,18 到 23 之间是视觉无损区间,我一般用 20。
ffmpeg -framerate 30 -i frames/frame_%05d.png \ -c:v libx264 -crf 20 -pix_fmt yuv420p \ -movflags +faststart output.mp4这里-pix_fmt yuv420p很重要,不加的话某些播放器可能无法解码。-movflags +faststart是把元数据放到文件开头,方便在线播放时快速起播。
提示:如果你的截图尺寸是奇数,比如 1921x1081,ffmpeg 会报错,因为 yuv420p 要求宽高都是偶数。解决办法是在截图时就设成偶数尺寸,或者加
-vf "pad=ceil(iw/2)*2:ceil(ih/2)*2"自动补齐。
4. 完整实操流程与核心环节实现
4.1 环境准备与依赖安装
先把环境搭起来。你需要 Node.js(建议 18 以上)、ffmpeg(建议 6.0 以上)。Node.js 装好之后,初始化项目并安装依赖:
npm init -y npm install roughjs playwright npx playwright install chromium这里npx playwright install chromium是下载 Chromium 浏览器,国内网络可能会慢,建议配置镜像或者耐心等待。如果失败,可以试试npx playwright install chromium --with-deps,它会顺便装系统依赖。
ffmpeg 的安装稍微麻烦一点。Windows 用户可以去官网下载完整版压缩包,解压后把bin目录加到环境变量PATH里。验证是否装好,命令行输入ffmpeg -version,能看到版本号就行。macOS 用户用brew install ffmpeg更省事。
注意:ffmpeg 的 Windows 包有多个版本,建议选带
libx264的完整版,不然合成时会提示编码器不存在。
4.2 渲染页面的搭建与逐帧绘制逻辑
渲染页面就是一个 HTML 文件,里面放一个 Canvas,然后引入 rough.js 和我们的渲染脚本。核心是renderFrame(frameIndex)函数,它根据帧号计算出当前进度,然后清空画布、重绘所有元素。
const canvas = document.getElementById('canvas'); const rc = rough.canvas(canvas); const ctx = canvas.getContext('2d'); const elements = [ { type: 'line', start: 0, duration: 30, x1: 100, y1: 200, x2: 800, y2: 200, seed: 42 }, { type: 'text', start: 20, duration: 20, x: 100, y: 300, content: '代码画白板', seed: 43 } ]; function renderFrame(frameIndex) { ctx.clearRect(0, 0, canvas.width, canvas.height); elements.forEach(el => { const progress = Math.min(1, Math.max(0, (frameIndex - el.start) / el.duration)); if (progress <= 0) return; if (el.type === 'line') { const cx = el.x1 + (el.x2 - el.x1) * progress; const cy = el.y1 + (el.y2 - el.y1) * progress; rc.line(el.x1, el.y1, cx, cy, { roughness: 1.8, seed: el.seed }); } else if (el.type === 'text') { ctx.globalAlpha = progress; ctx.font = '48px sans-serif'; ctx.fillText(el.content, el.x, el.y); ctx.globalAlpha = 1; } }); }这段逻辑的关键是progress的计算,它把帧号映射到 0 到 1 之间。线条的终点根据 progress 插值,文字用透明度渐变。这样每个元素都有自己的时间窗口,互不干扰。
4.3 截图脚本的编写与批量执行
截图脚本用 Playwright 写,逻辑很直接:启动浏览器、打开页面、循环调用renderFrame、截图保存。这里有个细节要注意,截图前要等一帧,确保 Canvas 绘制完成。我用requestAnimationFrame来等,比setTimeout更可靠。
const { chromium } = require('playwright'); const path = require('path'); const fs = require('fs'); (async () => { const totalFrames = 300; const outDir = path.resolve('frames'); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir); const browser = await chromium.launch({ args: ['--disable-gpu', '--no-sandbox'] }); const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 }); await page.goto('file://' + path.resolve('render.html')); for (let i = 0; i < totalFrames; i++) { await page.evaluate((idx) => window.renderFrame(idx), i); await page.evaluate(() => new Promise(r => requestAnimationFrame(r))); const filename = `frame_${String(i).padStart(5, '0')}.png`; await page.screenshot({ path: path.join(outDir, filename) }); if (i % 30 === 0) console.log(`已截图 ${i}/${totalFrames}`); } await browser.close(); console.log('截图完成'); })();跑这个脚本,你会看到控制台每隔 30 帧打印一次进度。300 帧大概 40 秒到 1 分钟能跑完,取决于机器性能。
4.4 ffmpeg 合成与参数微调
截图完成后,frames目录里应该有一堆 PNG。用 ffmpeg 合成:
ffmpeg -framerate 30 -i frames/frame_%05d.png \ -c:v libx264 -crf 20 -pix_fmt yuv420p \ -movflags +faststart output.mp4如果想让视频体积更小,可以把-crf调到 23 或 25,画质损失在可接受范围内。如果发现视频播放时颜色偏暗,可能是色彩空间的问题,可以加-vf "scale=in_range=full:out_range=tv"试试。
合成完成后,用播放器打开output.mp4检查。重点看几个地方:笔迹是否连贯、文字是否清晰、有没有跳帧。如果发现某段动画太快或太慢,回去调整配置里的duration参数,重新截图合成即可。
5. 常见问题与排查技巧实录
5.1 截图空白或半成品怎么办
这是最常见的问题,原因通常是截图时页面还没渲染完。排查思路分三步:第一,确认renderFrame函数是否被正确调用,可以在函数里加console.log看输出;第二,确认等待逻辑是否生效,requestAnimationFrame在某些情况下可能不触发,可以改成await page.waitForTimeout(16)作为兜底;第三,检查 Canvas 尺寸是否和 viewport 匹配,如果 Canvas 比 viewport 大,截图只会截到左上角。
还有一种可能是字体没加载完。如果用了自定义字体,建议在页面里用document.fonts.ready等字体加载完成后再开始截图。
5.2 ffmpeg 报错“找不到编码器”或“宽高必须是偶数”
“找不到编码器”通常是 ffmpeg 版本不带libx264。解决办法是换一个完整版,或者改用-c:v mpeg4作为临时方案,但兼容性和压缩率会差一些。
“宽高必须是偶数”是因为yuv420p像素格式的要求。最简单的办法是在截图时就把 viewport 设成偶数,比如 1920x1080。如果已经截好了奇数尺寸的图,可以用 ffmpeg 的pad滤镜补齐:
ffmpeg -framerate 30 -i frames/frame_%05d.png \ -vf "pad=ceil(iw/2)*2:ceil(ih/2)*2" \ -c:v libx264 -crf 20 -pix_fmt yuv420p output.mp45.3 笔迹抖动不一致或画面闪烁
这个问题基本可以锁定是seed没固定。rough.js 每次调用如果不传seed,会用随机种子,导致每帧的笔迹都不一样。解决办法是给每个元素分配固定种子,并且在渲染函数里始终传同一个值。
另一个可能的原因是 Canvas 没有完全清空。如果上一帧的残留没擦干净,会出现重影。确保每帧开头调用ctx.clearRect(0, 0, canvas.width, canvas.height)。
5.4 合成后的视频体积过大
视频体积主要取决于分辨率、帧率、CRF 值和内容复杂度。如果体积太大,可以按优先级调整:先把-crf从 20 调到 23,体积能降 30% 左右;如果还不够,把帧率从 30 降到 24;最后才考虑降分辨率。白板视频内容简单,其实用 720p 也够看,体积能小一半以上。
下面这张表是我实测的几个参数组合,供参考:
| 分辨率 | 帧率 | CRF | 时长 | 体积 |
|---|---|---|---|---|
| 1920x1080 | 30 | 20 | 10s | 约 2.5MB |
| 1920x1080 | 30 | 23 | 10s | 约 1.6MB |
| 1280x720 | 30 | 23 | 10s | 约 0.9MB |
| 1280x720 | 24 | 25 | 10s | 约 0.6MB |
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 截图空白 | 渲染未完成 | 加 requestAnimationFrame 等待 |
| 笔迹每帧不同 | seed 未固定 | 给每个元素传固定 seed |
| ffmpeg 报编码器错误 | 版本不带 libx264 | 换完整版 ffmpeg |
| 宽高奇数报错 | yuv420p 要求 | 用 pad 滤镜补齐或改偶数尺寸 |
| 视频体积大 | CRF 太低 | 调高 CRF 或降帧率 |
| 画面闪烁 | Canvas 未清空 | 每帧 clearRect |
| 字体不显示 | 字体未加载 | 等 document.fonts.ready |
6. 进阶玩法与个人经验补充
6.1 把配置抽成 JSON 实现批量生产
跑通基础流程之后,我做了一个改动:把所有元素配置抽到一个 JSON 文件里,渲染脚本读取这个文件来生成画面。这样一来,想换一套文案或者调整时间轴,只需要改 JSON,不用动代码。更进一步,可以写一个循环,读多个 JSON 文件,批量生成多条视频。我试过用这套流程一晚上生成了 20 条不同内容的科普短视频,每条只需要改配置,效率比手工剪辑高太多了。
6.2 用 Excalidraw 做前期草图
虽然最终是代码画,但前期构思画面布局时,我习惯用 Excalidraw 先画个草图。它也是手绘风格,拖拽元素很方便,适合快速试排版。定好位置之后,把坐标抄到配置里,再用 rough.js 渲染。这样比直接在代码里盲调坐标要直观得多。Excalidraw 有网页版,也有桌面版,Windows 上装个安装包就能用,不依赖网络。
6.3 几个让我少走弯路的经验
第一,先跑通最小闭环再优化。我一开始就想把参数调完美,结果卡在细节上很久。后来改成先用默认参数跑通“渲染-截图-合成”全流程,看到视频出来了,再逐个调参数,效率高很多。
第二,截图目录要定期清理。300 帧 PNG 大概占几百 MB,跑几次就堆满了。我在脚本里加了一行,每次开始前清空frames目录,避免磁盘爆掉。
第三,ffmpeg 命令建议写成脚本。参数一多容易敲错,写成build.sh或者build.bat,每次改完配置直接跑脚本,省心。
第四,注意中文字体渲染。Playwright 默认用的字体可能不含中文,导致文字变成方块。解决办法是在 CSS 里指定font-family: "Microsoft YaHei", "PingFang SC", sans-serif,确保中文能正常显示。
这套方案我用了大半年,从最初的手忙脚乱到现在基本能稳定产出,中间踩的坑基本都写在上面的。如果你也想试试用代码画白板视频,建议从最简单的“一条线加一行字”开始,跑通了再往上加元素。别一上来就搞复杂动画,容易劝退。