返回全部作品

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——照片进来、转发出去、结果回来,不落盘、不写日志。其余全部是浏览器本地计算。

photoGIF — 1
photoGIF — 2
上线
2026
我的角色
独立开发
技术栈
ReactTypeScriptWeb WorkerCloudflare Workers