动态手写效果:从预设 SVG 到 CMS 驱动的生成与播放架构
把写死在代码里的 Hello world,改造成可在 CMS 编辑和预览的手写区块:服务端生成、内容哈希复用、私有 Blob 存储与 SVG 播放,以及两次动画故障的排查记录。
我的首页原来有一段手写的「Hello world」。它看起来像逐笔写出来的文字,实际却是一份写死在代码里的 SVG:先去 Demo 输入文案,下载生成结果,再把路径保存进仓库。
只展示一句话时,这个办法很直接。但每次改文案都要重新导出、替换代码、发布,CMS 里的文字配置也无法真正决定页面上的笔迹。我希望保留自然、克制的连笔效果,同时让编辑者像修改普通文本一样修改它。
这次改造的核心,是把生成笔迹和播放笔迹拆开:编辑阶段把文字变成可复用的路径数据,访客访问页面时只播放 SVG。下面记录这套实现,以及上线时遇到的两个动画问题。
我要的是书写轨迹
最初的效果来自 sjvasquez/handwriting-synthesis。这个项目实现了 Alex Graves 的循环神经网络手写合成实验;仓库也展示了通过不同 style 和 bias 改变笔迹的方式。
普通手写字体可以排出像手写的文字,但字体轮廓不等于笔的运动轨迹。要做自然的逐笔动画,还需要知道哪里落笔、哪里抬笔,以及各段路径的先后顺序。直接描绘字形外轮廓,往往会变成“沿着字的边缘走一圈”。
我的实现采用与评估过的 Calligrapher 模型格式兼容的推理适配器,输出轨迹,再转换成 SVG 路径。它没有把整个 Python 仓库搬进前端,也没有生成一份新的 TTF 字体。
第一版只处理一行、规范化后 1–50 个受支持的英文字符,默认选择 09 Rounded。标点仍在允许的字符表里,但首页暂时不加标点:生成的感叹号可能出现笔杆和圆点错位,Clarity 和随机种子会影响结果,当前没有专门的标点修正算法。
先分清编辑端与访问端
用户输入任意文字的生成器,适合在浏览器中运行模型。但博客首页的文案由编辑者决定,修改频率远低于访问频率。没有必要让每位访客都下载模型、重新推理同一句话。
因此,生产链路由三个部分组成:
CMS 预览和保存后的后台资源准备负责生成;网站服务端只读取已有结果,不在访客请求里触发模型推理。资源不存在或读取失败时,组件显示当前文案,并保留原来的布局空间。
这降低了访客端的工作量,但不代表首屏成本为零。SVG 路径仍然增加 HTML 体积;网站服务端缓存未命中时,也需要等待内部资源读取。这里避免的是把模型下载和推理放到每次页面访问中。
CMS 用 Block,生成结果用 Blob
手写内容属于页面中的一个区块,所以我把文字、风格、Clarity、随机种子和播放速度放进 handWriting Block,没有额外建立一个必须先创建再关联的 Collection。
生成结果则存入 Blob,由系统维护。编辑者管理的是页面内容,不需要管理一份“手写资源目录”。即使目前只有首页使用,这个区块仍然可以出现在其他页面。
复用不依赖页面 ID,而依赖生成输入的指纹。实际缓存键覆盖以下内容:
// 结构示意:字段顺序固定,再对序列化结果计算 SHA-256。
const canonical = {
schema: SCHEMA_VERSION,
model: MODEL_SHA256,
generator: GENERATOR_VERSION,
geometry: GEOMETRY_VERSION,
input: normalizeInput({ text, style, seed, legibility }),
}规范化会合并连续的普通空格,并去掉首尾空格。因此,Hello world 和 Hello world 在其他参数相同时会使用同一份资源。
不过,仅仅文字相同还不够。不同风格、种子或 Clarity 会产生不同笔迹,也必须产生不同的键。模型和生成算法版本同样参与计算,否则升级算法后可能继续读到旧结果。
速度、颜色、页面 ID 和 Block ID 不参与这个指纹。两个页面可以复用同一份路径,以不同速度和颜色播放;其中一个页面修改文案,也不会改变另一个页面的内容。
结果路径采用 handwriting/v1/<fingerprint>.json。写入时关闭随机后缀并禁止覆盖,使相同指纹对应不可变资源。同一进程里的重复请求共享一个 Promise;不同服务实例仍可能同时计算,但后写入者会读取已经成功保存的结果。这减少了重复工作,却不是保证全局只执行一次的分布式锁。
模型和笔迹数据分别保存
模型存放在我们自己的私有 Blob 中,路径为 handwriting/models/<model-sha256>.bin。加载时先读取私有副本,校验固定 SHA-256,再解析成进程内可复用的模型。
只有私有文件不存在时,才允许从已配置的导入源下载、校验并写入。存储服务报错或文件损坏时,不会悄悄回退到外部源。开发和生产使用独立存储,模型权重不提交进代码仓库,也不随首页发送给访客。
生成后的 JSON 则保存文字、指纹、viewBox 和笔画列表。每笔只有路径 d、标准速度下的 duration 和累计 delay。几何转换按照抬笔标记分段,再用三次贝塞尔曲线连接采样点;绘制时长按近似路径长度分配,而不是让每一笔耗时相同。
网站项目不需要连接这个 Blob。它通过带内部鉴权的 Admin 接口读取 JSON,在服务端校验指纹、路径格式、坐标范围和大小后渲染。网站缓存以指纹建立标签,生成完成后可以使依赖该结果的缓存失效。
模型来源与实现也需要区分:本文公开的是集成代码和架构,没有附带模型权重或公开模型下载服务。保存一份私有副本本身不代表获得再分发许可。
把 SDK 拆成可以独立使用的入口
这次没有先创建独立仓库,而是在现有 monorepo 中增加私有包 @chankay/handwriting。这样可以一起调整协议、CMS、网站和 Storybook,减少早期跨仓库版本协调。
| 入口 | 职责 | 使用方 |
|---|---|---|
/schema |
输入规范化、指纹、结果校验 | Admin、网站、预览 |
/generator |
模型加载与笔迹生成 | Admin、Worker |
/browser |
Worker 生命周期、取消与生成请求 | Storybook Playground |
/react |
读取结果并播放 SVG | CMS、网站、Storybook |
包没有统一导出所有功能的根入口。只播放已有结果的组件直接导入 /react,不需要引入模型推理模块。
import { Handwriting } from "@chankay/handwriting/react"
// artifact 是已校验的生成结果;这个组件不负责生成。
<Handwriting artifact={artifact} speed={0.3606} />等协议和使用场景稳定后,再决定是否拆成独立仓库、对外发布。内部可复用的 SDK 已经解决了当前需求。
CMS 预览和 Storybook 的职责不同
CMS 的预览对未保存的编辑做 400 毫秒防抖,通过现有登录会话请求服务端生成结果。快速连续输入时,旧请求的迟到结果不会覆盖新文案。
“重播”只播放已有笔迹;“再写一次”改变未保存的随机种子。预览本身不会保存或发布页面,但生成结果可能已经写入 Blob。这意味着,最终没有保存的编辑也可能留下暂时无人引用的资源。
保存页面后,已有的后台资源任务会遍历嵌套区块,准备手写结果,再继续截图等工作。生成期间如果页面再次被编辑,任务会重新读取页面,避免拿旧快照覆盖新内容。
Storybook 则分成两种演示:普通播放故事使用固定 JSON,无需模型服务;Playground 在点击生成后才加载 Worker 和模型,用来试验文案、风格和参数。它的结果不写入生产 Blob。
生产 CMS 和网站使用同一份服务端结果,因此不需要依赖浏览器与 Node 推理结果完全一致。Worker 演示只是独立的试验入口。
SVG 动画遇到的两个问题
等待中的笔画提前露出圆点
播放器为路径设置 pathLength="1",通过 stroke-dashoffset 从 1 变到 0 表现书写过程。每一笔的 delay 和 duration 都除以 speed,因此速度调整不会重新生成路径。
最初,只隐藏路径长度仍会让等待中的笔画露出小圆点。原因与圆形端点有关:stroke-linecap="round" 的起点可能在未正式绘制时仍可见。
修复是让等待中的路径保持 visibility: hidden,进入动画后才设为可见,并用 forwards 保留完成状态。选择减少动态效果的用户直接看到完整笔迹。
热力图淡入结束时闪回空白
上线检查还发现,手写下方的热力图有些格子在淡入后会短暂闪空。连续读取计算样式时,捕获到了透明度从接近 1 回落到 0 的情况。
热力图原来由 Motion 播放淡入。对该版本实现的排查发现,动画结束时会更新 Motion 的值并取消原生动画,而最终 DOM 样式的写入存在时序间隙,可能短暂暴露初始的 opacity: 0。
需要说明的是,手写改造当时没有修改热力图组件,也没有升级 Motion。页面加载与动画时序改变是否触发了这个现象,尚未通过旧版与新版的对照实验证明,不能仅凭“改造后发现”就断言直接因果。
最终把热力图淡入改成由 CSS 完整控制,并使用 animation-fill-mode: both 保留等待和结束状态,消除了这次观察到的交接问题。生产首页的一次完整播放验证覆盖 252 个有色格子、1,544 次采样,没有再次捕获透明度回落。这是一次具体环境下的验证,不是所有浏览器都经过穷尽测试。
让手写和热力图一起完播
两组动画最后通过 CMS 参数对齐。当前笔迹在 1× 下约需 2.291 秒,热力图最后一个有色格子完成淡入约需 6.353 秒,所以手写速度取:
speed = 原始笔迹时长 / 目标时长
= 2.291 / 6.353
≈ 0.3606保存速度不会改变笔迹指纹,也不需要重新运行模型。调整后,浏览器一次实际播放测得两者结束时间相差约 80 毫秒,视觉上基本同步。
这仍然是针对当前内容的时长匹配。热力图数据、文案或加载时序变化后,结果可能变化;配置相近的时长,不等于两组动画共享同一个时钟。
当前边界与后续扩展
这套架构已经支持英文短句、CMS 预览、跨页面结果复用和轻量播放。一次本地集成观察中,缓存未命中的生成与落盘约需 2.2 秒,规范化后的重复请求约需 0.2 秒;这些数值包含当时的本地环境和存储访问,不能当作生产性能承诺。
多语言还没有实现。增加 CMS 的 locale 字段只是配置层工作,模型的字符集、训练数据和生成能力也要支持相应语言;后续若加入不同语言或模型路由,必须同步调整指纹协议。
无人引用资源的自动清理也暂未实现。将来需要同时考虑已发布页面、草稿、历史版本、未保存预览和正在执行的生成任务,不能只扫描当前首页后就删除其他文件。
对这个博客而言,最有价值的结果是:文案成为真正可编辑的内容,生成结果成为可复用的数据,而前端播放器只关心如何把这些路径写出来。
实现代码见 动态手写集成 PR、私有模型存储 PR 和 热力图动画修复 PR。