ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

代码绘制白板视频:从SVG路径动画到Agent Skill封装

代码绘制白板视频:从SVG路径动画到Agent Skill封装 白板视频大概是解说类内容里最讨巧的一种呈现没有真人出镜、不用复杂建模画风天然带一种“上课板书”的亲切感。但真上手做过的朋友都知道它最磨人的不是创意而是执行——用剪辑软件一帧帧对位置、拿数位板手绘录屏任何一处改稿都可能让前面十几分钟白干。我最近把一个标准流程做成了一个可复用的 skill这条白板视频每一笔都是代码画出来的输入一段文案输出一支逐笔绘制的成片中间不手工碰一帧。刚接触这个思路的人通常会问一句代码画出来的线会不会很生硬答案是会除非你愿意在“手绘感”上做文章。这篇文章我就把整套方案的原理、工具链、踩坑过程拆开讲从 SVG 路径动画的底层逻辑到怎么封装成一个能反复调用的 Agent skill尽量一次说透。想自己动手做白板动画、批量生成教学视频或者做 AI 辅助内容生产的可以直接照着抄作业。1. 不是剪辑不是手绘是“代码逐笔绘制”1.1 为什么我放弃剪辑和手绘录屏先说清楚一个认知白板视频并不适合用传统剪辑思路来做。剪辑软件里最常见的方式是“导入手绘素材”或者“套用白板模板”但这类方案有硬伤——所有笔画都是预渲染的视频片段你想改一个字、调一个顺序就得重新录或者重新导版本一多素材库先崩溃了。手绘录屏看起来自然可它对设备、手稳度、录音环境的要求都很高而且一旦画面里有长直线手抖问题会瞬间暴露。换成代码绘制之后画面里每一个对象都是结构化数据线条的起终点、圆弧的半径、文字的轮廓路径、每笔的先后顺序全部存在一个 JSON 或脚本数组里。修改只需要改参数不需要重新录渲染结果完全可预期。对我来说这才是白板视频应该有的生产方式——它本质上是“把画画这件事程序化”。另一个原因是批量生产能力。做知识类账号的人应该有体会一期视频里往往有十几条图每条图只改几个字。如果用剪辑软件等于重复劳动用代码画只需要写一个小函数去替换文本和坐标。我封装这个 skill 之后从分镜脚本到 SV G 路径生成再到视频编码一条命令跑完中途没有任何人肉介入点。1.2 这个 skill 的完整工作流先给大家看一个全局流程避免后面看代码时蒙圈。这个 skill 的核心不是“画图”而是把“文字脚本”转成“逐笔动画”的管线输入一段解说文案或分镜描述比如“先画一个圆中间写一个‘值’字然后从圆的右侧引出一条折线指向数据”。由 agent 把分镜解析成结构化绘制指令每条指令包含图形类型、坐标、笔宽、颜色、绘制顺序、期望绘制时长。渲染程序拿到这些指令后生成对应的 SVG 路径并对每个路径做“描边动画”处理。用无头浏览器逐帧截图输出 PNG 序列。最后交给 ffmpeg 合成视频再和配音轨合并。为什么要坚持走“结构指令 → SVG 路径 → 帧序列 → 视频”这条路而不是直接在浏览器里录屏因为帧序列可以精确控制时间轴。我可以让某一条线在第 3.2 秒开始画在第 4.1 秒画完每一帧都对应确定的画面内容这样音频和画面能精确卡节奏。录屏方案在时间精度上完全做不到这一点。2. 让每一笔像人手画动画原理与参数细节2.1 SVG 路径描边动画的原理与计算先讲底层原理这是整个 skill 的地基。SVG 里画一条线本身很简单关键是怎么让它呈现出“笔正在画”的效果。常用的手段是两根属性stroke-dasharray和stroke-dashoffset。假设一条路径的总长度是L如果我把它的stroke-dasharray设为L那么就表示这条路径上只画一段长度为L的实线再把stroke-dashoffset也设为L等于把这段实线整体向后偏移L看起来就完全不显示了。然后我让stroke-dashoffset从L逐渐变到0实线就会从起点被“拉出来”形成描边动画。代码表达如下const path document.querySelector(#myPath); const length path.getTotalLength(); // 初始化遮罩 path.style.strokeDasharray length; path.style.strokeDashoffset length; // 用 Web Animations API 做过渡 path.animate( [ { strokeDashoffset: length }, { strokeDashoffset: 0 } ], { duration: 1200, easing: cubic-bezier(0.4, 0.0, 0.2, 1.0) } );这里有个新手容易踩的坑getTotalLength()必须在路径完全渲染之后才能拿到正确值如果拿到的长度是 0多半是元素还没挂载到 DOM或者字体、CSS 还没加载完。后面排查部分我会再展开。至于每笔要画多久不是随便定的和路径长度、视觉速度有关。我自己的经验公式是单笔时长 路径长度 / 目标速度目标速度取多少白板视频里大约 800 像素每秒到 1200 像素每秒比较舒服。太快会看不清笔尖移动太慢又拖沓。举个例子一个 600 像素宽、300 像素高的矩形框总周长是(600300)*21800像素按 900 像素每秒算画完整个矩形需要 2 秒一个半径 150 像素的圆周长约 942 像素按 1000 像素每秒算大约 0.94 秒画完。实际使用中我会在速度后面追加一个“停顿系数”。画完一条线后不要立刻画下一条而是停顿 0.2 到 0.4 秒。因为人手在换笔、抬笔时是有呼吸感的无缝衔接反而显得机械。图形类型尺寸示例路径长度建议时长直线800px 横线800px1.0s矩形框600 x 3001800px2.0s圆形半径 150约 942px0.95s文字“AI”单字轮廓约 500-800px1.5-2.5s2.2 手绘感的三个关键抖动、粗细和速度曲线如果只是把直线匀速画出来成品会非常“假”因为你看到的是一条没有生命的机械轨迹。要让它像人手画的我总结下来至少要做三件事。第一是路径抖动。真实的铅笔笔画不是严格光滑的直线而是带有微小波动的。我常用的方案是用rough.js的底层算法生成扰动点或者自己在原路径上做离线偏移。比如一条直线我在渲染前先按每 20 像素一个采样点给每个点加上一个均值为 0、标准差 1.5 像素的高斯噪声再把这些点用贝塞尔曲线连起来。1.5 像素这个值是我试过的舒适区小于 1 像素基本看不出来大于 4 像素会显得手抖得厉害。// 用 rough.js 生成手绘感路径 const rc rough.canvas(document.querySelector(canvas)); rc.line(100, 200, 800, 200, { stroke: #1f2937, strokeWidth: 3, roughness: 1.2, bowing: 2 });第二是笔宽变化。手绘笔画通常不是全程一样粗起笔和收笔处会稍淡或稍细。在 SVG 里要做出这种效果可以把一条线拆成多段小线段逐段调整stroke-width。比如一段 800 像素的直线拆成 10 段前半段从 3.5 逐渐变成 3中间保持 3后 1/4 再从 3 变成 3.2。视觉效果会立刻不一样。第三是速度曲线。人画画时有明显的加速和减速过程尤其起笔瞬间速度慢中间快收笔前又慢下来。所以我给每笔设置了非线性的缓动cubic-bezier(0.4, 0.0, 0.2, 1.0)也就是先慢后快再慢。短线条可以适当缩短时间长线条则必须有停顿节点否则观看者会感觉“这条线是被机器拖完的”。2.3 笔画顺序白板画面里的“遮挡关系”另外一个很容易被忽略的问题是笔画之间的先后顺序。白板动画最终呈现的效果笔尖是浮在画面上方的但动画回放时你不能按空间顺序随便画。我总结了一个规则先画背景层再画前景层先画轮廓后画局部。举个例子要画一个“房子加树”的画面正确顺序是先画树的树干和树冠再画出房子主体最后画窗户和门。如果反过来房子画好了树的线条再从房子前面穿过就会形成遮挡错误像个 bug。这不是技术问题是绘制顺序的逻辑问题但很多第一次做代码白板动画的人都会栽在这里。我通常会在每个绘制指令里显式声明zIndex字段渲染前先按zIndex排序。这样哪怕 agent 生成指令时顺序乱了渲染器也能保证上层的图形最后画出来。3. 实操用代码做出第一支白板视频3.1 工具链安装与渲染环境搭设说完了原理直接进入实操。我这里用的是 JavaScript 系技术栈因为 SVG 和 Canvas 的生态最成熟无头渲染也好控制。核心依赖如下- Node.js 18 - svgdotjs/svg.js读取和创建 SVG 矢量图形 - roughjs生成手绘风矢量路径 - puppeteer无头浏览器负责逐帧截图 - ffmpeg把 PNG 序列合成视频安装命令很简单mkdir whiteboard-skill cd whiteboard-skill npm init -y npm install svgdotjs/svg.js roughjs puppeteerffmpeg 不在 npm 管需要单独安装。Windows 可以从官网下载macOS 用brew install ffmpegLinux 用 apt 或 yum。装完后直接跑ffmpeg -version确认能用就行。这里补充一个我认为很重要的经验如果只是做少量视频你用模块化代码或单脚本都行但如果要做成可复用的 skill最好把“SVG 生成”和“视频编码”分开。SVG 生成是一个纯函数输入是绘制指令输出是一个 SVG 字符串视频编码是另一个独立模块。这样以后想换渲染引擎或者只在服务器端生成静态 SVG 都能拆得开。3.2 写一个脚本画出第一组图形我直接用一个“画一个矩形和一条折线箭头”的小例子来演示。先建一个scene.js里面定义一个绘制指令数组const scene [ { id: rect, type: path, d: M 200 300 L 800 300 L 800 600 L 200 600 Z, stroke: { width: 4, color: #1f2937 }, zIndex: 1, duration: 2.4 }, { id: arrow, type: path, d: M 800 450 L 1100 450 L 1250 300, stroke: { width: 4, color: #ef4444 }, zIndex: 2, duration: 1.6 } ];然后用 SVG.js 把指令转成 SVGconst { SVG } require(svgdotjs/svg.js); const draw SVG().size(1920, 1080); scene.forEach(item { const path draw.path(item.d).stroke(item.stroke); const length path.length(); path.attr({ stroke-dasharray: length, stroke-dashoffset: length, fill: none }); }); draw.svg(); // 拿到完整的 SVG 字符串这里要注意一点SVG.js 的path.length()在路径还没插入 DOM 的情况下可能拿不到准确值。保险做法是把 SVG 字符串先插入到一个隐藏 DOM 节点里渲染完成后再取长度。我自己在服务端跑的时候是把 SVG 内容写入临时文件用 puppeteer 打开时再统一计算长度和动画。3.3 用 puppeteer 逐帧截屏并用 ffmpeg 合成拿到 SVG 之后下一步是逐帧渲染。我这里用 puppeteer 打开一个本地 HTML 文件通过page.evaluate控制动画进度。const browser await puppeteer.launch({ headless: true }); const page await browser.newPage(); await page.setViewport({ width: 1920, height: 1080 }); await page.setContent(svgContent, { waitUntil: load }); const fps 30; const totalDuration 5; // 秒 const totalFrames totalDuration * fps; for (let i 0; i totalFrames; i) { const time i / fps; await page.evaluate((t) { // 在页面里把时间轴指针同步到 t 秒 document.querySelectorAll(.animated-path).forEach((path) { const duration parseFloat(path.dataset.duration); const start parseFloat(path.dataset.start); const progress Math.min(1, Math.max(0, (t - start) / duration)); const offset path._totalLength * (1 - progress); path.style.strokeDashoffset offset; }); }, time); await page.screenshot({ path: ./frames/frame-${String(i).padStart(4, 0)}.png }); } await browser.close();然后合成视频ffmpeg -y -framerate 30 -i ./frames/frame-%04d.png -c:v libx264 \ -pix_fmt yuv420p -crf 18 whiteboard.mp4-crf 18是我试出来的经验参数画质接近无损文件体积还能接受。如果你要发网上-crf 23也能用体积能小不少。这里再给一个建议不要在帧率上偷懒。25 帧/秒看起来也能用但线条动画一旦有轻微的帧跳跃就会被放大30 帧/秒是比较稳妥的起步配置。3.4 中文手写字的处理方案所有做白板视频的人都会卡在这一步怎么让中文文字也像手写出来英文可以用字体自带的 script 字形中文笔画复杂单靠路径轮廓模拟书写顺序比较麻烦。我目前用的方案是走“字形轮廓拆段”的思路。做法是用opentype.js加载一个手写风格中文字体文件比如开源字体。对每个字符调用font.getPath(text, x, y, fontSize)拿到组成字形的所有轮廓路径。把所有轮廓路径按“从上到下、从左到右”的顺序排序再对每段轮廓单独做描边动画。这个方案不算完美因为中文字里有些笔画的内部顺序仍然和真实书写不同但视觉上已经能模拟出一个“正在落笔”的效果。再加上 0.3 到 0.5 秒的随机停顿观众基本看不出是代码画的。我试过的另一个方案是用现成的 SVG 笔画库每个字都有一段按笔画顺序排列的路径数据精确度极高但覆盖的字库有限遇到生僻字就凉了。做量产内容时我更推荐字形轮廓拆段因为它的覆盖率取决于字体文件只要字体存在所有字都能处理。4. 避坑常见故障排查与调试手记4.1 路径瞬现、闪线和消失我在调试过程中遇到的问题按概率从高到低排第一个就是“路径第一帧就完整出现”。这个现象的原因通常是stroke-dashoffset初始化没生效或者你在设置 dasharray 之前页面已经完成渲染。排查方法是确认路径的总长度是在元素挂载后取的并且把stroke-dasharray和stroke-dashoffset同时设置不要分开写在不同的事件里。还有一个常见坑就是某些浏览器会把dasharray的数值四舍五入导致明明设置了L显示出来却差几个像素。解决方法是给dashoffset留点余量比如设置L 1动画结束那一帧再强制归零。闪线问题则多是我在脚本里用了 Web Animations API又同时手动修改stroke-dashoffset两个动画互相打架。记住一个原则动画控制要么全交给 JS 时间轴要么全交给 CSS不要混用。4.2 帧序列时长不一致有人会发现自己渲染出来的帧序列画面跟脚本里设置的时长对不上。这通常是因为没有统一“全局时钟”。我给每个路径都标记了startTime和duration在每次准备渲染一帧时先算当前时间t再计算每个路径在当前时间应该画到哪个进度最后统一设置。还有一个很小的细节设置帧索引时从第 1 帧开始命名不要从第 0 帧开始。ffmpeg 对frame-0000.png的支持不如frame-0001.png稳定容易在合成时丢头一帧。4.3 中文文字显示成方块或字体丢失这个问题的根源是 SVG 渲染时找不到字体。如果你在 SVG 字符串里直接写了font-family: FZXiaoZhuanTi但运行环境没装这个字体浏览器就会用默认字体替代看起来就是方块。稳妥的办法是直接引用font-face并用绝对路径加载字体文件svg xmlnshttp://www.w3.org/2000/svg defs style font-face { font-family: HandFont; src: url(/fonts/hand-font.woff2) format(woff2); } /style /defs text font-familyHandFont数值/text /svg如果是 puppeteer 渲染确保字体文件能被本地服务访问到不能用相对路径。再不行就把字符轮廓转成path再渲染这能绕开字体依赖。4.4 渲染性能太慢帧图太多导致磁盘爆掉白板视频动辄几十秒30 帧率下手写 10 秒内容就是 300 张 PNG单张 2MB几分钟就占掉几 GB。我有一次渲染一支 3 分钟视频帧图把临时目录撑满直接中断了。后来我改成边渲染边编码不落地所有帧图。可以用 ffmpeg 直接从 stdin 读入 PNG 流ffmpeg -f image2pipe -framerate 30 -i - -c:v libx264 -pix_fmt yuv420p output.mp4在 Node 端每截完一张图就通过管道写入 ffmpeg 的 stdin这样磁盘占用被压得非常低只保留临时帧文件夹里的最后一张用于调试。4.5 常见问题速查表现象可能原因解决方向路径首帧完整出现dashoffset 未生效或字体未加载挂载后取长度再初始化 dashoffset部分路径开始后瞬闪动画控制器和 CSS 动画冲突统一由全局时间轴驱动画完的线后面又消失了路径被后续绘制覆盖检查 zIndex 和 fill 透明度中文显示成方块字体未加载或被替换嵌入字体或用路径字形每帧截图都很慢页面无谓重排用 translate 做合成层减少布局变化视频开头有一帧黑屏帧索引从 0 开始改为从 1 开始命名5. 封装成可复用的 Agent skill而不是一次性的脚本5.1 Skill 的输入和输出协议项目做到后面我已经不满足于“写死一个场景”于是把这一段流程封装成了一个可复用的 skill。所谓 skill简单说就是一个带固定输入输出协议的工具包调用者只需要按约定传入描述它就能返回一段完整成片。我设计的输入协议是{ script: 先画一个圆然后在圆的左侧画一条折线最后写一个‘步骤一’, style: 手绘白板, duration: 12, resolution: 1920x1080 }输出协议是{ video: /output/whiteboard.mp4, frames: /output/frames/, timeline: /output/timeline.json }这样设计的好处有两点一是接入到 AI agent 时模型不需要理解“SVG 路径”和“描边动画”它只需要生成一段符合输入协议的结构化内容二是以后要换画风比如从白板换成粉笔风格只改渲染层协议层不用动。5.2 一键生成分镜和绘制指令作为一个 skill它其实可以拆成两层逻辑第一层是让 agent 把自然语言转成分镜 JSON第二层是把分镜 JSON 转成 SVG 路径并渲染。第一层的实现我一般直接让语言模型输出结构化 JSONitems: [ { type: circle, center: [540, 400], radius: 150, zIndex: 1, expectedDuration: 1.0 }, { type: line, points: [[540, 400], [300, 450], [200, 380]], zIndex: 2, expectedDuration: 1.4 } ]第二层则由渲染引擎负责。我之前踩过的坑是让语言模型直接生成 SVG 路径结果它经常会生成无效的d参数。所以现在我只让模型生成“语义级”的图形参数再由代码自动计算贝塞尔曲线和路径长度可靠性高得多。5.3 部署到内网服务器或本地服务的经验很多场景下这类 skill 需要在离线环境或内网服务里跑。其实这套代码没有用到任何第三方在线 API所有核心逻辑都在本地 Node 进程里天然适合内网部署。你只需要保证服务器上装好了 Node、ffmpeg 和字体文件即可。部署时我建议用进程管理工具把渲染服务常驻比如用pm2跑一个 HTTP 接口POST /render Content-Type: application/json body: { script: 先画一个圆..., style: whiteboard }接口收到请求后进入排队渲染完成后返回视频文件地址。这样 agent 可以远程调用视频批量生产也很方便。我实测过一台 4 核 8G 的云服务器处理一支 10 秒 1080P 白板视频从生成指令到视频编码完成大约 25 秒属于完全可用的状态。5.4 去掉“AI 味”的几个小技巧最后聊聊怎么让成品更像人做的。很多用代码生成的白板视频问题不是“画得糙”反而是“太标准”。所有线条粗细一致、停顿一致、速度一致一眼就能看出来是机甲手。我常用的办法有三个。第一画完关键内容后加一个 0.4 到 0.6 秒的“悬停停顿”模拟笔尖在画面里思考的感觉。第二给线条速度加一个全局扰动比如在基准速度上乘以0.9 Math.random() * 0.2每条线的速度都不一样。第三在音频剪辑上让配音的节奏和笔画不完全紧贴必要时故意让某个笔画比配音内容早 0.3 秒出现制造一点“老师边讲边画”的随意感。这些细节单拎出来都不起眼加到一起观感提升很明显。我个人在实际操作里最大的体会是白板视频的代码绘制核心不在画线而在时间轴。线条谁都能画但把每条线放进一个既符合视觉节奏又贴合配音内容的时间轴里才是 skill 真正值钱的地方。如果你也想做建议先别贪大从“一个场景、五条线、一段 10 秒的配音”开始把这一套管线跑通再逐步加文字、加图形、加全局扰动。笔迹调好之后内容生产其实就是源源不断的参数输入而已。
返回列表