代码驱动白板动画:rough.js+Playwright+ffmpeg批量生成手绘视频
发布时间:2026/10/29 17:47:38 锦皓数字建站

1. 这套白板视频方案到底在解决什么问题第一次看到每一笔都是代码画的这个说法我脑子里蹦出来的第一个念头是这不就是把 Excalidraw 的手绘风格搬到视频里吗但真正动手做过白板动画的人都知道传统路子有多难受。要么用 After Effects 一帧一帧摆位置要么用专门的白板动画软件导出慢、改起来更慢想批量生产基本等于做梦。而这条方案的核心思路是把画这个动作彻底代码化——用 rough.js 生成手绘风格的线条用 Playwright 驱动浏览器逐帧渲染最后用 ffmpeg 把帧序列压成视频。整条链路没有一处需要手动拖拽改一个参数就能重新出一版。先说清楚这套东西适合谁。如果你是需要批量产出知识科普、产品讲解、教程类短视频的内容创作者这套方案能让你把画白板这件事变成可复用的脚本如果你是前端或者测试方向的工程师手里本来就熟 Playwright那上手成本几乎为零因为你只是把平时写测试用例的能力换了个用途如果你完全不懂代码那这篇可能读起来会有点吃力但我会尽量把每一步拆到能抄作业的程度你照着改参数也能跑起来。它解决的问题其实很具体手绘风格视觉 逐笔绘制动画 批量可复现。这三件事单独拆开都不难难的是同时满足。rough.js 负责手绘质感它画出来的线自带抖动和粗糙边缘比纯 SVG 直线有温度得多Playwright 负责精确控制每一帧的画面状态你可以理解为它是个能编程的录屏师想让它第几帧画到哪一笔它就画到哪一笔ffmpeg 负责把成千上万张 PNG 拼成流畅视频顺便处理帧率、编码、音画同步这些脏活。三者串起来就是一条完整的白板视频生产线。我实测下来最大的感受是这套方案真正的价值不在于能做出白板视频而在于能稳定地、参数化地做出无数条白板视频。传统软件做一条要两小时改一个错别字要重做半小时这套方案做一条可能初次搭建要花半天但之后每出一条新视频改改文案和配置几分钟就能跑完。这个边际成本的差异才是它值得折腾的理由。2. 整体架构拆解三个角色各干各的活2.1 为什么是 rough.js 而不是直接画 SVG手绘风格的本质是不完美。真实的粉笔字、马克笔线条边缘是抖的粗细是不均匀的起笔收笔有轻重变化。纯 SVG 画出来的线太干净、太数学一眼就是机器产物。rough.js 的做法是在你给定的路径上叠加随机扰动让每条线看起来像是手抖着画出来的。它支持线条、矩形、圆形、椭圆、多边形、路径等多种图元还能控制粗糙度roughness、弯曲度bowing、填充风格hachure、zigzag、cross-hatch 等。选它的核心理由是它输出的是 SVG 或 Canvas 指令天然适合被程序控制。你可以精确知道第 100 毫秒时这条线画到了 30%因为 rough.js 生成的路径本身就是可以用 stroke-dasharray 或者逐段绘制来控制的。这一点非常关键——白板动画的本质就是控制绘制进度如果图形库不给你这个控制权后面全白搭。提示rough.js 的 roughness 参数别调太高。我一开始图省事设成 3结果线条抖得像帕金森观众看着头晕。实测 1.5 到 2 之间最舒服既有手绘感又不至于太乱。2.2 Playwright 在这里扮演什么角色很多人对 Playwright 的印象停留在自动化测试工具觉得拿它做视频有点杀鸡用牛刀。但恰恰是它的几个特性让它成为这套方案的理想选择。第一它能精确控制浏览器渲染时机page.evaluate可以让你在页面里执行任意 JS包括控制 rough.js 的绘制进度第二它支持截图page.screenshot能把当前画面存成 PNG而且可以指定 clip 区域、省略背景等第三它能等待字体、图片加载完成避免出现字还没渲染出来就截图的尴尬。更妙的是Playwright 的截图速度相当可观。我实测在 1280x720 分辨率下单张截图大约 30 到 60 毫秒一秒钟的视频按 30 帧算就是 30 张图大概 1 到 2 秒能截完。一条 60 秒的视频截图阶段大概两三分钟完全在可接受范围内。如果你追求更高帧率比如 60 帧时间翻倍但画质会明显更顺滑尤其是快速书写的那几笔。2.3 ffmpeg 收尾从帧序列到成品视频ffmpeg 在这条链路里干的是最后一百米的活。前面 Playwright 吐出来的是一堆按序号命名的 PNG比如 frame_0001.png、frame_0002.pngffmpeg 要做的就是把这些静态帧按顺序拼成视频流选好编码器、码率、帧率输出 mp4。命令本身不复杂但参数选不对出来的视频要么糊、要么卡、要么体积爆炸。这里有个容易被忽略的点帧率必须和截图时的帧率严格一致。如果你截图时是按每秒 30 帧的节奏控制绘制进度那 ffmpeg 合成时也必须用-r 30否则视频会变速。我踩过一次坑截图时按 30 帧算的进度合成时手滑写了 24结果整条视频慢了 25%配的背景音乐全对不上只能重来。3. 核心细节与实操要点把每一笔都控制住3.1 绘制进度的控制原理白板动画看起来是一笔一笔画出来本质上是在时间轴上控制每条路径的可见长度。假设一条线总长 100 像素第 1 秒画到 50 像素第 2 秒画到 100 像素那观众看到的就是这条线在两秒内被画出来。实现方式有两种一种是用 SVG 的stroke-dasharray和stroke-dashoffset把虚线间隔设成路径总长通过改变 offset 来露出不同长度另一种是直接重绘每帧根据进度重新生成路径。rough.js 生成的路径长度不好直接拿到所以更稳妥的做法是用 dasharray 方案。具体来说先用path.getTotalLength()拿到路径总长 L然后设stroke-dasharray Lstroke-dashoffset L * (1 - progress)。progress 从 0 到 1线就从无到有画出来。这个方案的好处是性能好不需要每帧重新生成路径只需要改一个属性。但 rough.js 有个坑它一条线可能由多段子路径组成因为要模拟手绘的抖动getTotalLength()拿到的是整条复合路径的长度dasharray 作用上去后绘制顺序是沿着路径走的视觉上就是一笔画完效果反而更自然。我试过用单段路径画出来太规整少了那种手一笔带过的感觉。3.2 时间轴与帧的映射关系假设你要做一条 30 秒的视频帧率 30那就是 900 帧。你需要把每个绘制动作分配到具体的时间区间。比如第 0 到 2 秒画标题第 2 到 5 秒画第一个图标第 5 到 8 秒画第一条连接线……这个分配过程最好用配置表来管理而不是硬编码在代码里。我习惯用一个 JSON 结构来描述整条视频{ fps: 30, duration: 30, scenes: [ { type: text, content: 什么是白板动画, start: 0, end: 2, x: 100, y: 80 }, { type: rect, start: 2, end: 4, x: 100, y: 160, w: 300, h: 120 }, { type: arrow, start: 4, end: 6, from: [400, 220], to: [600, 220] } ] }这样改内容只需要改 JSON代码逻辑完全不用动。渲染时遍历 scenes对每个元素计算它在当前帧的 progress然后设置对应的 dashoffset。这个配置驱动的思路是整套方案能批量复用的关键否则每做一条视频都要改代码那和手动做也没区别了。3.3 字体与中文字符的处理中文白板视频有个绕不开的问题字体。rough.js 本身不处理文字文字得用普通的方式渲染但又要和手绘风格协调。我的做法是选一款手写风格的中文字体比如站酷快乐体、方正手迹这类然后在页面里通过font-face加载。Playwright 截图前必须确保字体加载完成否则截出来的是默认字体风格全毁。等待字体加载有个可靠的办法在页面里用document.fonts.ready这个 PromisePlaywright 通过page.evaluate(() document.fonts.ready)等它 resolve 再开始截图。我一开始没做这一步前几帧的字全是宋体后面才变成手写体视频里字体突变特别出戏。注意中文字体文件通常好几 MB加载慢。建议在本地起一个静态服务器把字体和页面一起伺服别用 CDN否则网络波动会导致截图时字体没加载完。本地服务器用npx serve或者 Python 的http.server都行。3.4 截图性能优化900 张截图如果每张都重新加载页面那时间会长到无法忍受。正确做法是页面只加载一次然后通过 evaluate 改变绘制状态逐帧截图。Playwright 的page.screenshot支持clip参数只截取画布区域能省不少时间。另外把type设成png而不是jpeg虽然文件大一点但无损后面 ffmpeg 压缩时画质更好。还有个细节截图时把浏览器的deviceScaleFactor设成 2这样截出来的是 2 倍分辨率视频在高分屏上看着更清晰。代价是文件大小翻四倍截图时间也增加但画质提升明显。我一般做 1080p 视频时用 scale factor 2做 720p 时用 1 就够了。4. 完整实操流程从零跑通一条视频4.1 环境准备与依赖安装先把项目目录建起来初始化 npm然后装三个核心依赖mkdir whiteboard-video cd whiteboard-video npm init -y npm install roughjs playwright npx playwright install chromiumffmpeg 不走 npm得单独装。Windows 上直接去官网下编译好的二进制解压后把 bin 目录加到 PATHmacOS 用brew install ffmpegLinux 用包管理器装。装完在终端敲ffmpeg -version能打印版本信息就说明成了。提示npx playwright install chromium有时候会因为网络问题失败多试几次或者换npx playwright install chromium --with-deps把系统依赖一起装上。如果一直失败检查一下是不是公司网络有限制换个网络环境再试。4.2 页面模板的编写建一个index.html里面放一个 canvas 或者 svg 容器引入 rough.js然后写一个全局函数renderFrame(progressMap)接收一个描述各元素进度的对象根据它重绘整个画面。这个函数是核心Playwright 每帧调用它一次。!DOCTYPE html html head meta charsetutf-8 style font-face { font-family: HandWrite; src: url(./fonts/handwrite.woff2) format(woff2); } body { margin: 0; background: #fdfcf8; } #stage { width: 1280px; height: 720px; position: relative; } .label { font-family: HandWrite, sans-serif; position: absolute; } /style /head body div idstage/div script src./node_modules/roughjs/bundled/rough.js/script script src./render.js/script /body /htmlrender.js里定义场景数据和渲染逻辑。关键是把每个元素的路径预先算好存起来每帧只改 dashoffset不重新生成路径。这样性能最好。4.3 逐帧截图的脚本用 Playwright 写一个capture.jsconst { chromium } require(playwright); const fs require(fs); const path require(path); (async () { const fps 30; const duration 30; const totalFrames fps * duration; const outDir path.join(__dirname, frames); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir); const browser await chromium.launch(); const page await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 2 }); await page.goto(http://localhost:3000/index.html); await page.evaluate(() document.fonts.ready); for (let i 0; i totalFrames; i) { const t i / fps; await page.evaluate((time) window.renderFrame(time), t); const filename frame_${String(i).padStart(5, 0)}.png; await page.screenshot({ path: path.join(outDir, filename), clip: { x: 0, y: 0, width: 1280, height: 720 } }); if (i % 30 0) console.log(已截取 ${i}/${totalFrames} 帧); } await browser.close(); console.log(截图完成); })();跑之前先在另一个终端起静态服务器npx serve -p 3000。然后node capture.js等着就行。900 帧大概两三分钟。4.4 ffmpeg 合成与参数选择截图完成后进 frames 目录执行ffmpeg -framerate 30 -i frame_%05d.png \ -c:v libx264 -pix_fmt yuv420p -crf 18 \ -preset medium -movflags faststart \ ../output.mp4逐个参数解释一下。-framerate 30是输入帧率必须和截图时一致。-c:v libx264用 H.264 编码兼容性最好。-pix_fmt yuv420p保证在各类播放器上都能正常显示不加这个参数有些播放器会花屏。-crf 18是画质参数范围 0 到 51越小画质越好体积越大18 是视觉无损的甜点值。-preset medium是编码速度与压缩率的平衡想快就改fast想小就改slow。-movflags faststart让视频支持边下边播网页嵌入时很有用。如果视频要配背景音乐加一个音频输入ffmpeg -framerate 30 -i frame_%05d.png -i bgm.mp3 \ -c:v libx264 -pix_fmt yuv420p -crf 18 \ -c:a aac -b:a 192k -shortest \ output.mp4-shortest保证视频长度以短的为准避免音频比视频长导致黑屏。5. 常见问题与排查技巧实录5.1 截图相关的高频问题问题一截出来的图是空白的。最常见的原因是页面还没渲染完就截图了。解决办法是在renderFrame里返回一个 Promise等所有绘制操作完成后再 resolvePlaywright 那边 await 它。另外确认一下page.goto之后有没有等networkidle如果页面有异步加载的资源不等的话可能截到半成品。问题二字体不对。前面说过等document.fonts.ready。如果还是不对检查字体路径是否正确以及font-face的format是否和文件实际格式匹配。woff2 写format(woff2)ttf 写format(truetype)写错了浏览器会忽略这个字体。问题三截图速度太慢。关掉deviceScaleFactor或者降低分辨率。另外page.screenshot的clip参数一定要用不裁剪的话会把整个视口截下来包括滚动条之类的杂物还慢。5.2 ffmpeg 合成的坑问题一视频卡顿或跳帧。九成是帧率不匹配。检查截图时的 fps 和 ffmpeg 的-framerate是否一致。还有一种可能是文件名序号不连续比如中间缺了 frame_00500.pngffmpeg 遇到缺帧会报错或者跳过导致视频时长不对。截图脚本里加个校验确保每帧都成功写入。问题二视频体积过大。调高 crf 值比如从 18 调到 23体积能小一半画质损失肉眼几乎看不出。或者用-preset slow让编码器多花点时间压缩。如果还是大考虑降低分辨率或者帧率。问题三颜色发灰或偏色。通常是-pix_fmt没设对。统一用yuv420p这是兼容性最好的像素格式。如果源图是带透明通道的 PNGffmpeg 默认会把它压成黑色背景需要加-vf formatyuv420p或者先把背景填成白色。5.3 绘制逻辑的常见错误错误一dashoffset 算反了。记住公式dashoffset L * (1 - progress)。progress 为 0 时 offset 等于 L线完全隐藏progress 为 1 时 offset 为 0线完全显示。我见过有人写成L * progress结果线是倒着画的从终点往起点长。错误二多个元素同时画。白板动画的节奏感来自一笔一笔来如果所有元素同时出现就失去了白板的韵味。在配置里给每个元素分配不重叠的时间区间前一个画完再画下一个。如果确实需要并行至少错开 0.3 秒以上视觉上才有层次。错误三路径长度计算时机不对。getTotalLength()必须在路径已经插入 DOM 之后调用否则返回 0。如果路径是动态生成的确保先 append 到页面再算长度再设 dasharray。5.4 问题速查表现象可能原因解决办法截图空白渲染未完成await renderFrame 的 Promise字体突变字体未加载完等 document.fonts.ready视频变速帧率不匹配统一截图与合成的 fps视频花屏像素格式不对加 -pix_fmt yuv420p线条倒画dashoffset 公式错改为 L*(1-progress)体积过大crf 太低调到 20-23缺帧报错文件名不连续校验截图完整性颜色发灰色彩空间问题检查输入输出格式6. 进阶玩法与批量生产思路6.1 用配置驱动批量出片单条视频跑通之后真正的价值在于批量。把场景配置抽成独立的 JSON 文件每条视频一个文件然后写一个批处理脚本遍历目录下所有 JSON依次跑截图和合成。我现在的做法是维护一个scripts/目录里面放几十个 JSON改完文案一键跑批一晚上能出十几条视频。更进一步可以把文案和场景配置解耦。文案用 Markdown 写场景配置用模板生成。比如一个三点式讲解模板接收标题和三条要点自动生成对应的场景 JSON。这样非技术同事也能参与内容生产只需要填文案不用碰代码。6.2 加入转场与音效纯白板绘制看久了会单调可以加一些转场效果。最简单的做法是在场景之间插入擦除或淡出的过渡帧用 ffmpeg 的xfade滤镜实现。音效方面给每一笔画配一个轻微的沙沙声能极大提升观看体验。音效文件按时间点用 ffmpeg 的adelay和amix混进去或者干脆在剪辑软件里后期加。6.3 适配竖屏短视频现在很多平台以竖屏为主把画布尺寸从 1280x720 改成 720x1280重新调整元素坐标就行。注意竖屏的视觉重心偏上标题和核心内容放在上半部分底部留出字幕区。帧率可以提到 60竖屏滑动时更顺滑。提示竖屏视频的截图分辨率建议用 1080x1920deviceScaleFactor 设 1 就够否则文件太大合成时间翻倍。6.4 自动化触发与部署如果内容更新频繁可以把整条链路接到 CI 上。提交 JSON 配置后自动触发截图和合成产物上传到对象存储生成分享链接。Playwright 在 CI 环境里跑需要装系统依赖用官方提供的 Docker 镜像最省事。ffmpeg 在 CI 里通常预装了没有的话加一行安装命令。我个人的体会是这套方案最爽的时刻是当你需要改一个错别字时只需要改 JSON 里一个字符串然后重新跑一遍五分钟后就拿到新视频。那种内容完全可编程的掌控感是任何图形界面软件都给不了的。当然前期搭建确实要花点时间尤其是调 rough.js 的视觉参数和 ffmpeg 的编码参数但这些都是一次性的投入后面每一条视频都在摊薄这个成本。最后分享一个小技巧截图时把中间帧也存一份低分辨率的缩略图合成前先快速拼一个预览版确认节奏和内容没问题再跑全分辨率的正式版。这样能省下大量等待时间尤其是做长视频的时候预览版几十秒就能看完正式版可能要跑十几分钟。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。