photoGIF
一张照片,一套微信能直接用的动态表情包
网页
网上流传的做法是三步:豆包垫图出九宫格,bgsub 抠背景,剪映导出 GIF。这套流程有三个必踩的坑——动图背景变成灰色、导出超过 1MB 传不进微信、AI 水印擦不掉。
这个站把三步合成一步,顺带把手动切九宫格和手动抠图也省了。除了 AI 生成那一步,抠图、动效、压缩全部在你自己的浏览器里完成,照片不上传服务器。
三个坑各自的解法
| 坑 | 成因 | 这里怎么做 |
|---|---|---|
| 背景变灰 | 视频编码不带 alpha,透明被压成灰底 | 不走视频,直接对透明贴纸做逐帧变换再编码成透明 GIF |
| 超过 1MB | 剪映不感知微信的表情规格 | 把 1MB 当成输入约束,沿阶梯自动试压 |
| 水印擦不掉 | 水印和主体在同一张图上 | 连通域面积筛选,水印作为小碎块被自动淘汰 |
抠图不用模型
提示词里强制「纯黑背景 + 白色描边 + 互不粘连」不是为了好看,是整条图像管线的输入契约。有了这个前提,一个抠图模型都不需要:
- 从画布四边洪水填充找背景,而不是用全局亮度阈值。全局阈值会把人物的黑头发、黑衣服、黑眼珠一起抠掉——这是最常见的翻车点。洪水填充进不到主体内部,内部的纯黑区域天然保住。
- 按合成模型估计 alpha。背景是纯黑,抗锯齿边缘的观测值就是
α·F。用固定亮度斜坡是错的:一个 50% 覆盖率的白色描边像素观测值是灰色 128,斜坡会把它判成完全不透明,于是贴纸边上留一圈灰边——换个白色聊天背景立刻露馅。正确做法是拿邻域内部像素的亮度当参考值F,反推α = 观测亮度 / 参考亮度。 - 反解去黑边:
F = C / α,把被背景压暗的边缘颜色还原回来。实测边缘平均亮度从 138 恢复到 255。 - 连通域标记一次干两件事:把九宫格拆成 9 张独立贴纸,同时把面积不达标的块(水印、噪点)丢掉。
动效与编码
12 种程序化动效,全部保证 f(0) === f(1),循环无跳帧。渲染前会先算出整段动画的包络再决定贴纸尺寸,否则弹跳到最高点那一帧会被画布裁掉。
编码走 gifenc 加 Web Worker。体积超预算时沿阶梯降级:先降色 → 再降尺寸 → 最后抽帧。顺序是有讲究的——贴纸是大色块加描边,降到 64 色几乎看不出差别,而抽帧会直接让动作变卡。抽帧后会把每帧延时按比例补回去,动作速度不变。
实测一张 900×900 的九宫格:抠图加切分 9 张 169ms,9 张 GIF 全部编码导出 821ms,输出 240×240、35–65KB,每帧带透明标志,附 Netscape 无限循环块。
已知限制
- 不支持 HEIC。iPhone 默认格式浏览器解不了,目前是给提示引导转格式。要支持得引入
libheif-wasm(约 1MB,得按需懒加载)。 - GIF 只有 1 位 alpha,半透明边缘会被硬切。因为提示词要求白色描边,切在白边上不明显;换成无描边的贴纸就会看到锯齿。
- 提示词里要求的「互不粘连」如果 AI 没遵守,两张贴纸会被当成一个连通域切在一起,这时得把碎块过滤调小手动处理。
技术
React + TypeScript + Vite,部署在 Cloudflare Workers。服务端只有一个 Worker,存在的唯一理由是藏住图像生成的 API Key——照片进来、转发出去、结果回来,不落盘、不写日志。其余全部是浏览器本地计算。


- 上线
- 2026
- 我的角色
- 独立开发
- 技术栈
- ReactTypeScriptWeb WorkerCloudflare Workers