前两天我把一条30秒的白板视频发到群里,有朋友留言:这白板动画做得挺顺滑,用的哪家模板?我说没有模板,这条视频里的每一笔,从标题“随机森林”的起笔,到每个树杈的末端,都是代码画的。具体说,是SVG路径逐帧推进加ffmpeg合成,最后MP4里的每一帧,本质上都是一张PNG。听起来有点绕,但效果真不差。今天我顺手把这套流程整理成了一个skill——就是现在AI Agent圈子里常说的那种技能包。这篇把底层原理、完整流水线和踩过的坑一次写清楚,想自己做白板视频、或者想给AI配一个“视频生成技能”的朋友,可以直接照着抄。
1. 白板视频的底层原理:一笔一笔是怎么动起来的
1.1 你看的是动画,大脑补的是“手”
白板视频之所以吸引人,不在于画面多精美,而在于它制造了一种“正在被创作”的临场感。观众看到一条线从起点慢慢延伸到终点,会不自觉脑补出一只手握着笔在画。这种感觉是整段视频的核心资产,而它完全不需要真实的手部视频——只需要让每一条线“按顺序、按速度”出现。
所以做这类视频的第一原则不是“画得像”,而是“画得有顺序”。任何一条路径,只要它从零开始慢慢变长,笔触方向明确、速度有快有慢,观众就会买账。搞懂了这一点,后面的技术选择就顺了。
1.2 核心魔术:stroke-dasharray 和 stroke-dashoffset
让SVG路径“慢慢长出来”的机制非常朴素,就两个属性:stroke-dasharray和stroke-dashoffset。
stroke-dasharray原本是用来画虚线的,比如stroke-dasharray="10 5"表示画10像素实线、空5像素,然后循环。如果我们只给一个值,比如stroke-dasharray="1400",就表示“整条路径都是实线段,长度1400”。而stroke-dashoffset控制的是这组虚线的起始偏移量——把它设为和路径长度一样,实线段会整体向左偏移出画面,看起来就是一条空白路径。
这时候只要把stroke-dashoffset从路径总长度逐渐减到0,实线段就会像被一支笔拖着一样,从路径起点一路画到终点。动画的本质就是:
path { stroke-dasharray: 1400; /* 与路径实际长度一致 */ stroke-dashoffset: 1400; /* 初始完全隐藏 */ }# 渲染循环里不断减小 stroke-dashoffset # 例如从 1400 -> 0,配合帧驱动SVG里可以用path.getTotalLength()拿到路径的真实长度,不需要自己量。这就是“每一笔都是代码画的”最底层的机关:不是播放现成的视频素材,而是每一帧都由代码实时计算这条线该显示多少。
1.3 从“机械匀速”到“真人手感”:缓动函数是关键
光会画线还不够。如果所有路径都匀速出现,动画看起来像CAD制图回放,完全没有手绘的温度。真实人手写字、画线的时候,笔尖速度是变化的:起笔时慢,中间快,收笔时又慢下来。这个速度曲线在代码里就是缓动函数。
我常用的映射关系是:
// progress: 0 -> 1,表示这一笔从开始到结束 const eased = easeInOutCubic(progress); el.style.strokeDashoffset = len * (1 - eased);easeInOutCubic会让进度变化呈现“慢-快-慢”的节奏。同一根线,用线性函数和用缓动函数,渲染出来的手感天差地别。线性是“机器在画”,缓动是“人在画”。我在skill里把所有路径的默认缓动都设成easeInOutCubic,只有少数快速箭头、标注线用easeOutSine,制造一种“快速带过”的感觉。
2. 这个skill装了什么:指令、脚本和规则
2.1 先统一一下“skill”是什么
最近的AI圈里,“skill”基本特指给智能体用的技能包:一段结构化的指令文件,外加配套脚本、素材目录和参考示例。大模型平时不会主动按你的行业标准干活,但你把一个skill放到它能加载的目录,它遇到相关任务时就会按你写好的流程、格式和验收标准执行。
换句话说,skill是“把老师傅的隐性经验变成AI能读懂的显性规范”。市场上有人拿它做PPT、做周报,我拿它做白板视频。本质上都一样:把“我会做这类视频”变成“AI也能稳定产出同类视频”。
2.2 技能包的目录结构
我整理的whiteboard-video-skill是这个样子:
whiteboard-video-skill/ ├── SKILL.md ├── scripts/ │ ├── build_scenes.py # 讲稿 -> 分镜JSON │ ├── render.js # SVG -> 逐帧PNG │ └── compose.sh # 帧序列 -> MP4 ├── examples/ │ ├── random_forest.json │ └── sample_scene.svg └── assets/ ├── pens/ └── background.pngSKILL.md是灵魂。它不需要很长,但必须把AI容易含糊的地方全钉死。我里面最关键的几条“硬性规则”是这样的:
# Whiteboard Video Skill ## 任务目标 把用户提供的讲稿/要点转换为白板手绘风格动画视频。 ## 工作流 1. 将讲稿拆解为场景(scene),每场景 4~10 秒。 2. 为每个场景规划元素清单,标注绘制顺序。 3. 输出 1920x1080 的 SVG 文件,所有图形必须使用 path。 4. 检查每个绘制元素是否带>{ "title": "随机森林", "voiceover": "随机森林是机器学习中的一种集成学习方法...", "style": { "background": "#ffffff", "pen_color": "#1f2937", "pen_width": 6, "fps": 25 } }build_scenes.py会把讲稿按语义拆成场景。比如开头3秒画标题,接下来10秒画一棵决策树,再接下来10秒画一片森林,最后用箭头和方框表示投票机制。每个场景都包含元素列表、绘制顺序、预计时长。拆完之后,agent会为每个场景生成对应的SVG工程文件。
3.2 SVG分镜规范:让渲染器认识“每一笔”
为了让渲染器不误解,每个SVG元素都带一组自定义属性,相当于给每条线写清楚“什么时候出场、画多快、用什么手感”:
<svg viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg"> <path d="M240,300 C420,120 680,520 920,240" stroke="#1f2937" stroke-width="6" fill="none" >// render.js 片段 for (let frame = 0; frame <= totalFrames; frame++) { await page.evaluate(({ frame, totalFrames }) => { const progress = frame / totalFrames; document.querySelectorAll('path[data-draw-order]').forEach(el => { const len = parseFloat(el.dataset.length); const start = parseFloat(el.dataset.start); const end = parseFloat(el.dataset.end); let p = (progress - start) / (end - start); p = Math.min(Math.max(p, 0), 1); const eased = easeInOutCubic(p); el.style.strokeDasharray = len; el.style.strokeDashoffset = len * (1 - eased); el.style.opacity = p > 0 ? 1 : 0; }); }, { frame, totalFrames }); await page.screenshot({ path: `frames/frame_${String(frame).padStart(4, '0')}.png` }); }注意这里出现了一个>ffmpeg -framerate 25 -i frames/frame_%04d.png \ -c:v libx264 -pix_fmt yuv420p -crf 18 output.mp4
-crf 18画质很高,适合白板视频这种大量纯色画面的内容;-pix_fmt yuv420p是兼容性关键,不加它很多播放器会显示不了。
3.4 30秒“随机森林”科普视频的实测配置
我用“随机森林”做过一次完整测试。30秒的视频拆成5个场景,配置如下:
| 场景 | 时间范围 | 核心元素 | 绘制时长 | 缓动 |
|---|---|---|---|---|
| 标题 | 0-4秒 | “随机森林”逐字写出 | 3.2秒 | easeInOutCubic |
| 一棵树 | 4-10秒 | 树干、左右枝、三片叶子 | 5.5秒 | easeOutSine |
| 整片森林 | 10-20秒 | 第二棵树、第三棵树、背景树阵淡入 | 8.2秒 | easeInOutCubic |
| 投票机制 | 20-28秒 | 箭头、投票箱、三张票 | 6.8秒 | easeOutBack |
| 收尾 | 28-30秒 | 整体上移并淡出 | 2.0秒 | linear |
背景树阵我特意没用逐笔绘制,而是让它们“淡入”——因为背景元素如果也逐笔画,观众注意力会被分散,主体树还没画完,眼睛已经不知道看哪了。白板视频的核心是叙事节奏,不是把所有东西都画一遍。
4. 最容易翻车的四个细节,亲测踩坑记录
4.1 多笔画闪烁:重叠路径和绘制顺序的冲突
第一次渲染一个复杂图形时,我发现明明画的是树干加树冠,结果树干快画完的时候,树冠边缘突然闪了一下。排查半天发现是两条路径在节点处有重叠:后画的笔画覆盖了先画笔画的端点,而由于两者透明度在动画过程中不同步,叠加区域会出现明显的“多画一笔”闪变。
解决方式是双管齐下。首先,在build_scenes.py里做重叠检查,凡是有公共端点的路径,统一收进同一个<g>组,让它们在时间轴上首尾相接而不是重叠;其次,每条路径绘制结束后强制停顿0.15到0.2秒再开始下一条,制造“笔离开纸面再落下去”的间隙。这个间隙不旦消除闪烁,手感反而更像真人。
4.2 手写字的坑:英文好办,中文得绕路
英文和数字的手写体很好处理,社区里有大量按字母笔画顺序排好的SVG路径,直接复用就行。中文就麻烦了,因为汉字几乎没有现成的“按标准笔顺拆好的路径库”,字体文件里拿到的是闭合轮廓,不是笔画顺序。
我试过三个方案,最终留下了最稳的一个:
方案一,从字库里提取轮廓拆笔画,能用但工程量大,且拆出来的笔画顺序不一定符合书写习惯;方案二,找现成的手写SVG字形集,渲染质量高但覆盖的字太少,换个生僻字直接漏;方案三,也就是我现在用的,逐字打字机显示 + SVG噪声滤镜模拟手抖。每个字作为一个tspan,按顺序出现,同时给整段文字加一个轻微位移扰动滤镜:
<filter id="handshake"> <feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="2" result="noise" /> <feDisplacementMap in="SourceGraphic" in2="noise" scale="3" /> </filter>滤镜让每个字有细微抖动,配合逐字弹出,观众感知上就是“手写”的。工程上最省心、全覆盖,任何中文文本都能直接渲染。唯一注意点是filter区域默认会裁剪边界,文字靠近画面边缘时会被切掉,所以给滤镜加个x="-10%" y="-10%" width="120%" height="120%"。
4.3 填充色“突然出现”的违和感
白板视频里画一个圆或一块区域,如果线条画完的瞬间填充色直接铺满,会让人觉得很“跳”,像模板网站的劣质转场。我后来做了个处理:填充色的透明度跟绘制进度联动,但不要线性联动——等笔画画到80%以后,填充才从0到1淡入。
const fillProgress = (p - 0.8) / 0.2; el.style.fillOpacity = Math.min(Math.max(fillProgress, 0), 1);这样给人的感觉是“轮廓刚画完,上色紧随其后”,视觉节奏非常自然。这个参数也可以调:0.8太早的话,有些笔画还没闭合就开始上色,会漏出来一小块;改成0.85更安全。
4.4 长视频的性能瓶颈:截图比计算贵
渲染一个30秒、25fps的视频,总共750帧。Puppeteer每帧截图一次,在我的机器上,中等复杂度的SVG大约需要2分钟左右。瓶颈不在JS计算,而在页面截图本身。
几个亲测有效的优化:不要每一帧都重新加载页面,整个渲染过程保持同一个page实例;SVG里隐藏不参与动画的元素,直接设置display:none,而不要靠opacity:0,后者仍会触发大量绘制计算;如果视频超过一分钟,建议分段渲染,比如每10秒输出一个片段,最后再合到一起,这样哪段出问题只需要重渲那一小段,不用全盘再来。
5. 想让AI稳定出片?输入规范和调参建议
5.1 给AI的输入要“结构化”
做这个skill过程中我最大的体会是:大模型不缺创意,缺的是约束。你不给它边界的格式,它就给你自由发挥,最后渲染器根本不认识。所以我的输入JSON必须包含字段名、取值范围和示例。
{ "title": "随机森林", "voiceover": "讲稿全文", "scenes": [ { "id": "scene1", "duration": 4, "elements": [ { "type": "text", "content": "随机森林", "drawOrder": 1, "drawDuration": 3.2, "easing": "easeInOutCubic" } ] } ] }AI按这个结构产出分镜后,build_scenes.py会继续补充每个元素的>
集成学习实战:Amazon评论质量预测中的特征工程与模型调优
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
搭建可复用、可换色的安全设备PPT图标库:从选型到VBA管理
简介:这是一份面向网络工程师、安全工程师、售前与方案设计人员的绿盟风格拓扑图图标库,以PPT为载体,集中整理绿盟科技及业界常用的网络、安全设备图标,可配合Visio或PowerPoint快速绘制网络拓扑、安全解决方案示意与汇报材料。资…
AI Skill调用数据接口的三种方式:scripts、CLI与MCP实战指南
最近收到好几个朋友的求助,症状出奇一致:装了一个AI Skill,让它查个数据、调个接口,结果要么一本正经地给你编一段根本不存在的“数据”,要么直接甩一句“无法访问外部数据”。还有人更冤,明明Skill装得挺完…
AllData+Coze-Loop构建可归因大模型自动化评测平台
1. 项目概述:这不是一个“搭个界面跑个API”的玩具项目我第一次看到“AllData集成Coze-Loop建设大模型评测平台”这个标题时,下意识点了暂停——不是因为看不懂,而是因为太懂了。过去两年里,我亲手搭过7套不同形态的大模型评估系统…
NPDP第二版:产品创新的系统化落地方法论
简介:本资源是PDMA(产品发展和管理协会)官方发布的《NPDP Body of Knowledge, Second Edition》中文版PDF指南,专为备考New Product Development Professional(NPDP)认证的产品经理、产品创新管理者及产品开…
一维时域信号里,AI 和传统方法谁更靠谱
前几天有个做振动监测的朋友问我:现在动不动就说上 AI,那我这套滤波加 FFT 的活是不是该扔了?我说你先别扔。他手上那台设备,一个月能跑出几百 G 的波形,真要一股脑塞给模型,可能连训练集都标不完。但在另一…