用代码生成手绘白板视频:rough.js + Playwright + ffmpeg 全流程
发布时间:2026/10/2 16:15:35来源:尧图网络
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 到 5118 到 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是把元数据放到文件开头方便在线播放时快速起播。提示如果你的截图尺寸是奇数比如 1921x1081ffmpeg 会报错因为 yuv420p 要求宽高都是偶数。解决办法是在截图时就设成偶数尺寸或者加-vf padceil(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 scalein_rangefull:out_rangetv试试。合成完成后用播放器打开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 padceil(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时长体积1920x1080302010s约 2.5MB1920x1080302310s约 1.6MB1280x720302310s约 0.9MB1280x720242510s约 0.6MB5.5 常见问题速查表问题现象可能原因解决办法截图空白渲染未完成加 requestAnimationFrame 等待笔迹每帧不同seed 未固定给每个元素传固定 seedffmpeg 报编码器错误版本不带 libx264换完整版 ffmpeg宽高奇数报错yuv420p 要求用 pad 滤镜补齐或改偶数尺寸视频体积大CRF 太低调高 CRF 或降帧率画面闪烁Canvas 未清空每帧 clearRect字体不显示字体未加载等 document.fonts.ready6. 进阶玩法与个人经验补充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确保中文能正常显示。这套方案我用了大半年从最初的手忙脚乱到现在基本能稳定产出中间踩的坑基本都写在上面的。如果你也想试试用代码画白板视频建议从最简单的“一条线加一行字”开始跑通了再往上加元素。别一上来就搞复杂动画容易劝退。
网站建设高端定制企业官网