2026/10/7 10:05:50

代码驱动白板视频:rough.js+Playwright+FFmpeg全链路实现

代码驱动白板视频:rough.js+Playwright+FFmpeg全链路实现 1. 这不是动画软件是用代码一笔一划“写”出来的白板视频你见过的白板视频大概率是用After Effects加手绘插件、或者用Explain Everything这类工具录屏生成的。但这次我要说的是另一种路径整条视频里每一根线条、每一个文字、每一次擦除全由JavaScript实时计算坐标、调用Canvas API逐帧绘制最后用FFmpeg合成——没有手绘、没有录屏、没有后期剪辑只有代码和时间轴。这个skill的核心关键词其实就三个rough.js手绘风格渲染、Playwright自动化控制浏览器执行绘制逻辑、FFmpeg帧序列合成与编码。它不依赖任何图形界面或人工干预所有视觉元素都来自纯逻辑驱动——比如“画一个带箭头的流程图”代码不是调用“插入箭头”按钮而是算出起点、终点、箭头角度、粗细衰减曲线再用rough.js的line()和arrow()方法在Canvas上落笔“擦除上一步”也不是播放一段擦除动画而是清空对应图层、重绘剩余内容。我第一次跑通这条链路是在给客户做技术方案演示时——他们需要每周生成20条“算法原理讲解”白板视频每条3分钟含动态公式推导流程图演进关键节点高亮。用传统方式一个视频至少2小时而用这套代码驱动方案从Markdown脚本输入到MP4输出全程57秒且可并行批量处理。更关键的是所有视觉行为完全可复现、可版本化、可A/B测试改一行坐标计算逻辑就能对比两种动画节奏对用户理解率的影响换一套rough.js的roughness和bowing参数就能生成不同“手绘感”的风格变体。这不是炫技而是把白板视频从“录制行为”变成了“计算过程”。它适合三类人需要高频产出标准化教学/产品演示视频的运营和讲师想把设计系统落地为可执行视觉规范的前端团队以及正在探索“代码即内容”新范式的创作者。如果你的视频需求开始出现“重复结构”“参数化变化”“需与数据联动”这三个特征中的任意一个那这套方案就值得你花两小时搭起骨架。2. 为什么不用SVG或Lottierough.js才是手绘感的底层解法很多人看到“代码画白板”第一反应是“直接用SVG描路径不就行了”或者“Lottie动画导出JSON加载快又轻量”。但实际踩过坑就会发现这两种方案在白板场景下存在根本性缺陷——它们解决的是“静态图形表达”而非“手绘行为模拟”。SVG的本质是矢量图形描述语言它擅长精准复现形状但无法表达“手绘的不确定性”同一根线人类画三次每次的抖动幅度、起笔压力、收尾顿挫都不同。而rough.js的底层设计恰恰反其道而行之——它不保存最终图形而是保存“绘制行为”。比如调用roughGenerator.line(x1, y1, x2, y2)时rough.js内部会根据当前roughness粗糙度和bowing弯曲度参数生成一组贝塞尔控制点将直线离散为15~30个微小线段数量随线长自适应对每个线段端点施加高斯噪声偏移偏移量 roughness * Math.random() * 线段长度最终用Canvas的moveTo()lineTo()逐点绘制形成天然抖动。提示rough.js的roughness: 1并不等于“最粗糙”而是基准值。实测中roughness: 0.8配合bowing: 0.3最接近专业白板笔触——太高的roughness会让线条像毛线团太低则失去手绘感变成机械直线。Lottie的问题更隐蔽它依赖AE导出的JSON动画数据而AE本身无法原生模拟“笔尖在纸面拖拽”的物理过程。所有“手绘效果”都是靠预设缓动曲线逐帧位移实现的伪随机一旦需要动态响应数据比如根据API返回的数值实时调整箭头长度就必须重新导出整个动画JSON丧失了代码驱动的灵活性。我们做过对比测试用同一套流程图逻辑分别用SVG硬编码、Lottie JSON、rough.js Canvas三种方式实现“逐步展开”动画。结果如下方式首帧渲染耗时内存占用3分钟视频动态参数修改成本手绘真实感评分1-5SVG硬编码12ms8.2MB修改1处需重写全部路径2.1Lottie JSON45ms15.6MB修改1处需AE重导出替换JSON3.4rough.js Canvas28ms3.7MB修改1处参数全局自动重绘4.8关键差异在于rough.js的每次调用都是“即时计算即时绘制”没有中间状态缓存。这意味着你可以用一个for循环控制绘制节奏// 模拟手写文字逐字出现带轻微延迟和抖动 const text 机器学习; for (let i 0; i text.length; i) { setTimeout(() { // 计算当前字符位置考虑前序字符宽度 const x baseX charWidth * i Math.random() * 2; const y baseY Math.sin(Date.now() * 0.001) * 1.5; // 微小垂直波动 ctx.fillText(text[i], x, y); }, i * 120 Math.random() * 30); // 基础延迟随机扰动 }这种“行为级控制”是SVG/Lottie永远做不到的——它们只能告诉你“第3帧显示A第5帧显示B”而rough.js告诉你“当i2时笔尖在(x,y)落点压力值为0.7”。3. Playwright不是用来测试的是作为“无头白板引擎”被调用的提到Playwright90%的开发者第一反应是“自动化测试框架”。但在这套白板视频生成链路中它的角色彻底变了它不是在模拟用户操作而是在充当“浏览器环境调度器”——精确控制Canvas绘制时机、截取指定分辨率帧、管理内存生命周期。为什么非得用Playwright我们试过Puppeteer、Selenium甚至纯Node.js的jsdom最终全被放弃原因很现实jsdom没有Canvas实现它连document.createElement(canvas)都返回null更别说getContext(2d)Puppeteer的截图精度问题在高DPI屏幕如Mac Retina下page.screenshot()默认按设备像素比缩放导致导出帧模糊且无法精确控制截取区域的物理像素尺寸Selenium启动慢、内存泄漏严重单次生成1000帧需启动10次浏览器实例Selenium常驻进程内存占用飙升至2GB任务队列卡死。Playwright的破局点在于它的双模式渲染架构在headless: true模式下它使用Chromium的Headless Shell支持完整的Canvas 2D API且截图为物理像素级精确通过fullPage: falseclip: {x,y,width,height}指定区域更关键的是它提供了page.evaluateHandle()方法能将Canvas DOM节点直接暴露为JS Handle后续操作无需序列化/反序列化——这意味着你可以用await canvasHandle.evaluate(canvas canvas.toDataURL())直接获取Base64图像比page.screenshot()快3倍以上。我们的标准工作流是这样组织的// 1. 启动Playwright设置固定视口避免DPI干扰 const browser await chromium.launch({ headless: true }); const page await browser.newPage(); await page.setViewportSize({ width: 1920, height: 1080 }); // 2. 注入rough.js和绘制逻辑注意必须在页面加载后注入 await page.addScriptTag({ path: node_modules/roughjs/bundled/rough.js }); await page.evaluate(() { window.rough new RoughCanvas(document.getElementById(canvas)); // 初始化绘制上下文... }); // 3. 关键用page.evaluateHandle获取Canvas引用避免反复DOM查询 const canvasHandle await page.$(#canvas); // 4. 执行绘制逻辑传入参数返回帧数 const frameCount await page.evaluate((params) { // 这里执行所有rough.js绘制返回总帧数 return drawAnimation(params); }, animationParams); // 5. 逐帧截图核心优化点 for (let i 0; i frameCount; i) { // 等待当前帧绘制完成通过window.frameIndex标识 await page.waitForFunction(window.frameIndex ${i}); // 直接从Canvas Handle截取不经过页面渲染管线 const dataUrl await canvasHandle.evaluate(canvas canvas.toDataURL(image/png, 1.0) ); // 保存为./frames/frame_${i.toString().padStart(6,0)}.png await saveFrame(dataUrl, i); }注意page.waitForFunction()的等待条件必须是页面内可访问的全局变量如window.frameIndex不能是闭包变量。我们曾因误用const frameIndex i导致所有帧等待同一值生成1000张相同画面。这套方案让单帧生成时间稳定在18~22msi7-10875H远超Puppeteer的35ms。更重要的是Playwright的进程隔离机制保证了每条视频生成互不干扰——即使某条视频因逻辑错误导致Canvas内存溢出也只会杀死当前page实例不影响队列中其他任务。4. FFmpeg不是视频剪辑工具是帧序列到MP4的“确定性编译器”当rough.js在Playwright中画完1000帧得到的是1000张PNG文件。这时候很多人会本能地打开Premiere或Final Cut手动导入序列、调整时序、加转场。但这条路走到最后你会发现所有手动操作都在破坏“代码驱动”的确定性——今天加的淡入效果明天可能被运营要求改成滑入上周用的字体这周要替换成新品牌色。而FFmpeg的存在就是把视频生成变成一次make build式的编译过程。我们定义的FFmpeg命令模板长这样ffmpeg \ -framerate 30 \ -i ./frames/frame_%06d.png \ -c:v libx264 \ -profile:v high \ -level 4.2 \ -crf 18 \ -pix_fmt yuv420p \ -vf scale1920:1080:force_original_aspect_ratiodecrease,pad1920:1080:(ow-iw)/2:(oh-ih)/2 \ -y \ output.mp4拆解每个参数的真实作用不是文档抄来的是实测踩坑总结-framerate 30必须显式声明输入帧率。如果不加FFmpeg会按默认25fps读取导致1000帧被压缩成33.3秒视频1000÷25而不是预期的33.3秒1000÷30。这是新手最常犯的错误。-crf 18CRFConstant Rate Factor值越小画质越好但体积越大。实测crf 18在1080p下码率约8.2Mbps肉眼几乎看不出压缩痕迹crf 23虽体积减半但文字边缘出现明显块状噪点。-pix_fmt yuv420p强制YUV420色彩空间。这是几乎所有播放器兼容的基础格式。如果省略FFmpeg可能输出yuv444p导致iOS Safari无法播放。-vf滤镜链scale确保所有PNG按比例缩放到1080ppad在不足区域填黑边。这里的关键是force_original_aspect_ratiodecrease——它保证图像不被拉伸而是等比缩小后居中比简单-s 1920x1080安全得多。但真正让FFmpeg成为“编译器”的是它的确定性输出特性相同输入帧序列 相同FFmpeg版本 相同参数 → 100%相同的MP4二进制文件MD5校验一致而Premiere等GUI工具每次导出都会因GPU加速开关、后台进程干扰、缓存状态不同产生微小编码差异。我们曾用Git管理FFmpeg参数配置# ffmpeg-config.yaml video: framerate: 30 crf: 18 preset: slow audio: false filters: - scale: 1920:1080:force_original_aspect_ratiodecrease - pad: 1920:1080:(ow-iw)/2:(oh-ih)/2每次生成视频前脚本自动读取该配置生成命令确保所有团队成员产出完全一致的视频。当客户说“上个月的视频颜色更暖”我们直接git checkout对应commit重新make video5分钟内交付完全一致的版本——而不是在Premiere里凭记忆调色。5. 火山引擎不是CDN是白板视频的“智能分发中枢”生成MP4只是第一步。真正的挑战在于如何让这条“代码画出的白板视频”在微信、抖音、企业微信、官网等多个渠道以最优质量、最低延迟、最高兼容性触达用户这时候火山引擎的智能媒体处理IMP服务成了不可替代的一环。很多人以为火山引擎只是“国内版Cloudflare Stream”但它的核心价值在于深度适配中国网络环境的智能决策能力。举个具体例子我们一条1080p白板视频在上传到火山引擎后系统会自动执行以下动作多分辨率自适应转码生成720p主推、480p弱网、360p极弱网三档H.264视频同时生成AV1编码的1080p版本仅对Chrome 110用户提供节省40%带宽所有转码均采用-vsync 0丢帧保实时避免因GOP结构导致首帧加载延迟。智能CDN路由用户请求时火山引擎根据IP归属地、运营商、实时网络质量通过QUIC探测选择最优边缘节点实测数据显示北京联通用户访问上海节点平均延迟38ms而通过火山引擎智能路由95%请求落到华北节点延迟降至12ms。白板场景专属优化启用text-optimization模式对视频中文字区域进行局部锐化unsharp5:5:1.0其他区域保持平滑避免整体锐化带来的噪点开启motion-compensation针对白板视频特有的“局部运动”只有箭头移动背景静止单独优化运动矢量预测降低码率15%。最关键的是火山引擎提供的播放器SDK深度集成能力。我们不需要让用户下载MP4而是嵌入一个轻量JS SDKdiv idplayer-container/div script srchttps://sf1-ttcdn-tos.pstatp.com/obj/eden-music-sdk/v1.2.0/eden-player.min.js/script script const player new EdenPlayer({ container: #player-container, src: https://your-bucket.volces.com/video/xxx.mp4, // 自动启用火山引擎的智能ABR自适应码率 abr: true, // 白板视频专用开启文字增强 textEnhance: true, // 错误时自动降级到备用源如OSS直链 fallback: https://oss-your-bucket.aliyuncs.com/xxx.mp4 }); /script注意textEnhance: true参数会触发火山引擎的OCR局部增强流水线对视频中所有文字区域进行亚像素级锐化实测使微信内嵌播放器的文字可读性提升40%尤其在iPhone 12以下机型。这套方案让我们彻底摆脱了“上传→转码→分发”的割裂流程。现在开发同学提交一个JSON配置包含rough.js参数、Playwright脚本路径、FFmpeg配置CI/CD流水线自动触发生成→上传→火山引擎处理→更新CDN整个过程127秒。而以前用OSS手动转码平均耗时23分钟且经常因网络波动失败。6. 从“画一笔”到“生成一条视频”的完整工程链路现在把所有环节串起来给你展示一条白板视频从零到MP4的完整生命旅程。这不是理论流程而是我们生产环境每天跑500次的真实链路6.1 输入层用Markdown定义“绘制意图”我们不用复杂JSON或XML而是用极简Markdown语法描述动画逻辑# 算法流程图 ## 步骤1初始化 - 创建节点 A(输入数据) - 创建节点 B(模型训练) - 绘制箭头 A→B标签训练 ## 步骤2迭代过程 - 循环 5 次 - 高亮节点 B - 在B下方添加文本第${i}轮迭代 - 等待 800ms - 擦除所有文本 ## 步骤3输出 - 创建节点 C(预测结果) - 绘制箭头 B→C标签预测这个Markdown被解析为AST抽象语法树每个##标题对应一个“场景”每个-列表项对应一个“绘制指令”。解析器会自动补全坐标计算逻辑——比如创建节点 A(输入数据)会根据当前场景布局算法分配A在(200,150)并生成rough.js的circle()和text()调用。6.2 执行层Playwright驱动Canvas绘制Playwright页面加载后执行以下核心逻辑// 1. 初始化rough.js和Canvas const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); const rough new RoughCanvas(ctx); // 2. 按场景顺序执行每个场景独立Canvas图层 for (const scene of scenes) { // 清空当前图层 ctx.clearRect(0, 0, canvas.width, canvas.height); // 执行该场景所有指令 for (const instruction of scene.instructions) { switch(instruction.type) { case circle: rough.circle(instruction.x, instruction.y, instruction.radius); break; case arrow: rough.arrow(instruction.from.x, instruction.from.y, instruction.to.x, instruction.to.y, { stroke: instruction.color }); break; case text: ctx.font bold 24px sans-serif; ctx.fillStyle #333; ctx.fillText(instruction.content, instruction.x, instruction.y); break; } } // 标记当前帧完成触发截图 window.frameIndex currentFrame; // 等待下一帧由外部控制节奏 await new Promise(r setTimeout(r, instruction.delay || 100)); }6.3 输出层FFmpeg合成与火山引擎分发Playwright完成所有帧截图后触发FFmpeg命令# 生成帧序列已由Playwright完成 # 执行转码 ffmpeg -framerate 30 -i ./frames/frame_%06d.png \ -c:v libx264 -crf 18 -pix_fmt yuv420p \ -vf scale1920:1080:force_original_aspect_ratiodecrease,pad1920:1080:(ow-iw)/2:(oh-ih)/2 \ -y ./output.mp4 # 上传到火山引擎使用volc-sdk-js const client new VolcEngineClient({ region: cn-north-1 }); await client.upload(./output.mp4, videos/algorithm-demo.mp4); # 触发智能转码任务 await client.createTranscodeTask({ input: videos/algorithm-demo.mp4, outputs: [ { resolution: 1080p, codec: h264 }, { resolution: 720p, codec: av1 } ] });6.4 验证层自动化质量门禁每条视频生成后自动运行三重验证完整性检查用ffprobe确认视频时长帧数÷30关键帧数量≥总帧数×0.05文字可读性测试用Tesseract OCR提取视频前3秒文字匹配原始Markdown中的关键词准确率95%则告警播放兼容性测试用Playwright启动真实iOS Safari、Android Chrome、微信内置浏览器加载播放器SDK验证首帧加载时间1.2秒。只有三重验证全部通过视频才被标记为ready推送到CDN。否则进入人工审核队列——过去3个月自动拦截率12.7%其中83%是因rough.js的bowing参数设置过高导致文字扭曲。7. 这套方案的边界在哪里什么情况下不该用它再好的工具也有适用边界。我必须坦诚告诉你这套“代码画白板”方案并不适合所有视频场景。它是一把锋利的手术刀而不是万能瑞士军刀。以下是明确的不适用场景7.1 绝对不要用的三类需求第一类需要真人出镜或实拍素材混合白板视频的本质是“纯图形表达”。一旦加入人脸、产品实物、环境镜头整套链路就崩塌了——rough.js画不出毛孔纹理Playwright截不了实拍画面FFmpeg合成时会出现严重的时序错位。我们曾尝试用OpenCV抠像rough.js画框结果发现实拍部分的光照变化会让rough.js的阴影逻辑完全失效最终效果像PPT动画。第二类动画节奏高度依赖音乐节拍这套方案的节奏控制基于毫秒级setTimeout误差±3ms。而音乐节拍要求误差±10ms人耳可辨阈值。当动画需严格卡点如“鼓点响起时箭头弹出”JavaScript的事件循环抖动会导致肉眼可见的脱节。此时必须回归AE音频波形分析用audioContext.currentTime做硬件级同步。第三类需要复杂粒子特效或3D旋转rough.js只支持2D手绘Canvas 2D API也不支持Z轴旋转。所谓“3D效果”本质是用transform: rotateY()做的CSS 3D模拟但在Playwright无头模式下CSS 3D渲染支持不稳定且无法与rough.js线条自然融合。我们试过Three.jsrough.js混合结果是Three.js的WebGL画布和rough.js的Canvas画布无法共享抗锯齿设置边缘出现明显色差。7.2 性能临界点何时该切换方案我们用真实数据划定了性能红线单条视频时长 ≤ 5分钟Playwright内存占用可控1.2GBFFmpeg转码时间90秒单帧复杂度 ≤ 200个rough.js元素超过此数Canvas渲染帧率跌破25fps导致截图模糊动态参数组合 ≤ 100种比如“5种颜色×4种粗细×5种动画节奏100种”此时仍可用代码生成超过100种建议用AE模板JSON参数驱动。当你的需求突破任一红线我的建议是用这套方案生成“基础骨架”再用AE做“精细润色”。例如先用代码画出流程图主体和文字导出PNG序列再导入AE添加粒子飘落、镜头推近等效果。这样既保留了代码驱动的确定性又获得了专业动画的表现力。最后分享一个血泪教训我们曾为金融客户制作“K线图演变”白板视频要求实时渲染1000根K线。按常规思路每根K线用rough.js画4条线开盘、收盘、最高、最低总元素数4000直接OOM。最终解法是用Canvas原生API画K线绕过rough.js只对关键标注如“突破压力位”文字用rough.js——性能提升7倍且手绘感不打折。真正的工程智慧不在于用多少酷炫技术而在于知道在哪一刀切下去最准。