news 2026/10/2 16:15:33

用代码生成手绘白板视频:rough.js + Playwright + ffmpeg 全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用代码生成手绘白板视频:rough.js + Playwright + ffmpeg 全流程

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.mp4

5.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.5MB
1920x1080302310s约 1.6MB
1280x720302310s约 0.9MB
1280x720242510s约 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,确保中文能正常显示。

这套方案我用了大半年,从最初的手忙脚乱到现在基本能稳定产出,中间踩的坑基本都写在上面的。如果你也想试试用代码画白板视频,建议从最简单的“一条线加一行字”开始,跑通了再往上加元素。别一上来就搞复杂动画,容易劝退。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 16:15:28

Univer实战:实现指定单元格可编辑,其余锁定只读

做一个在线填表功能&#xff0c;模板制作者把一张Excel发给我&#xff0c;要求是&#xff1a;用户能填“姓名、电话、备注”这几列&#xff0c;但“评分、审核意见”这些列绝不能动。一开始我以为就是套几个input框&#xff0c;真正做完才发现&#xff0c;从单元格锁定、工作表…

作者头像 李华
网站建设 2026/10/2 16:15:26

Kettle 9.3 生产级部署避坑指南:驱动、时区、JSON与Linux定时任务

简介&#xff1a;本资源为Kettle 9.3最新版官方安装包百度网盘下载指南文档&#xff0c;面向数据工程师、ETL开发人员及大数据初学者&#xff0c;解决国内用户因网络限制难以获取Pentaho官方资源的痛点。文档以结构化方式详解Kettle核心能力&#xff08;跨平台运行、图形化Spoo…

作者头像 李华
网站建设 2026/10/2 16:14:28

Verilog条件语句优先级解密:if与case综合后的硬件行为差异

开门见山说一句&#xff1a;Verilog里的if和case&#xff0c;写的时候只是几行代码&#xff0c;但综合出来的电路优先级结构可能完全不一样&#xff0c;甚至同一段代码在不同的上下文里会有截然相反的硬件行为。我见过不少同事在这个问题上吃过亏&#xff0c;仿真一点问题没有&…

作者头像 李华
网站建设 2026/10/2 16:14:09

Codex 运行报错排查指南:十类高频问题与解决方案

1. 装完 Codex 却跑不起来&#xff0c;问题到底卡在哪Codex 这类命令行 AI 编程助手&#xff0c;装完之后敲下第一条命令就报错&#xff0c;几乎是每个新手都会经历的阶段。我自己第一次在 VSCode 里配 Codex 的时候&#xff0c;光是让它正常响应第一条请求就折腾了将近两个小时…

作者头像 李华
网站建设 2026/10/2 16:14:06

ECharts Tooltip配置详解:从基础到自定义富文本实战

1. ECharts Tooltip&#xff1a;数据可视化里最容易被低估的细节做前端数据可视化这行&#xff0c;有个特别有意思的现象&#xff1a;很多开发者能花大量精力去调图表的颜色、坐标系、数据样式&#xff0c;却往往忽视了光标悬停在图表上时那个小小的提示框——tooltip。但恰恰是…

作者头像 李华
网站建设 2026/10/2 16:13:45

群晖ABB整机恢复实战:从备份到裸机还原的完整实验手册

1. 先说结论&#xff1a;备份配好了&#xff0c;不等于灾难来临时能恢复我其实是被一次真实的教训逼着做这个实验的。前两年公司一台跑了财务系统的Windows Server 2016物理机&#xff0c;凌晨三点系统盘直接亮黄灯&#xff0c;第二天上班时已经进不去系统了。当时群晖NAS上的A…

作者头像 李华