ARTICLE DETAIL

资讯详情

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

白板动画视频制作:用SVG路径动画与ffmpeg实现手绘效果及AI技能封装

白板动画视频制作:用SVG路径动画与ffmpeg实现手绘效果及AI技能封装 前两天我把一条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-dasharray10 5表示画10像素实线、空5像素然后循环。如果我们只给一个值比如stroke-dasharray1400就表示“整条路径都是实线段长度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 viewBox0 0 1920 1080 xmlnshttp://www.w3.org/2000/svg path dM240,300 C420,120 680,520 920,240 stroke#1f2937 stroke-width6 fillnone >// 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 idhandshake feTurbulence typefractalNoise baseFrequency0.02 numOctaves2 resultnoise / feDisplacementMap inSourceGraphic in2noise scale3 / /filter滤镜让每个字有细微抖动配合逐字弹出观众感知上就是“手写”的。工程上最省心、全覆盖任何中文文本都能直接渲染。唯一注意点是filter区域默认会裁剪边界文字靠近画面边缘时会被切掉所以给滤镜加个x-10% y-10% width120% height120%。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会继续补充每个元素的>
返回列表