先看成品。一只鹈鹕骑自行车,黄昏的海边公路,整段动画是一个 2.4 秒的无缝循环:
下面这个是 SVG 本体,不是视频。你看到的画面是浏览器此刻实时渲染的:
这个测试是什么
「鹈鹕测试」最早来自 Simon Willison:让大模型直接写一个 SVG,画一只骑自行车的鹈鹕,用来考察它的空间想象力。静态版已经不够看了,我这次做的是会动的版本,加了两条硬约束:单文件自包含、纯 CSS keyframes,所有动画共用一个 2.4 秒周期并且循环闭合。这两条约束也是后面逐帧验收的前提——周期不统一,就无所谓无缝。
结论:只有浏览器能正确渲染 SVG 动画
SVG 动画只有两种写法:SMIL(<animate> 标签)和 CSS keyframes。如果你想把 SVG 转成图片或序列帧,要知道静态光栅化器——resvg、librsvg、ImageMagick、Inkscape——全都不播动画,只画第 0 帧。
我用一个圆从左滚到右的 2 秒 SMIL 动画实测过:headless Chrome 能精确截到 0.1s 和 1.5s 两帧,圆的位置与动画进度吻合;ImageMagick(底层 librsvg)渲染出来的图永远停在起点。所以想验证动画是否真的在动、动得对不对,只有一条路:headless Chrome 截帧。
验收怎么做
成片验收走机器断言,不靠肉眼:
- 无缝循环:t=0 与 t=2400ms 两帧用 ImageMagick
compare -metric AE对比,逐像素一致(AE = 0); - 运动存在:中间四帧两两对比,差异显著;
- 局部运动:裁切前轮区域跨帧对比,辐条和气门嘴在转。
腿部蹬踏是三段式关节(大腿/小腿/脚),12 个关键帧由 Python 按踏板位置反解,脚踝全程锁定在踏板中心上方 11px,不脱踏板、不穿模。
踩坑:虚拟时间快进不可靠
截中间帧我最初用 --virtual-time-budget,让 Chrome 把虚拟时钟快进到指定毫秒再截图。结果它对 <img> 内嵌的 CSS 动画不成立:Chrome 合成器用真实时钟驱动这类动画,同样的参数重复截四张,姿态对不上(md5 各不相同)。我最早用 SMIL 动画做试验时虚拟时间是准的,但我不想赌这两种机制的行为差异,统一换成了冻结姿态法:往页面注入 animation-play-state: paused,再用负的 animation-delay 把动画定格到任意时刻 t。同一姿态重复截图 AE = 0,确定性成立。
整条管线
- 手写 SVG,纯 CSS keyframes,所有动画 2.4s 整周期;
- headless Chrome 冻结姿态法逐帧截图,60 帧 @ 25fps;
- ffmpeg 合成 mp4(libx264, crf 17)和调色板优化的 GIF。
最后补一句边界:不涉及动画的静态 SVG 导出,resvg 或 librsvg 更快更轻,浏览器只在「要动」的时候才必要。