一、缘起:一个"没赚到钱"的静态表情包
起因很朴素——我之前手搓过一套静态表情包,发出去零收益。复盘下来,静态图在微信表情平台的完播/下载转化天然吃亏,而且没有固定 IP 主角,用户记不住。
于是这次定了三个硬指标:
- 做成动态 GIF(240×240、透明底、单图 ≤ 500KB);
- 必须有固定主角 IP(品牌记忆点,决定复下载率);
- 走强共鸣场景向(大学生日常:早八、干饭、被点名、摸鱼、卷王……),而不是纯可爱。
目标受众和主题定下来后,技术上的核心矛盾只有一个:怎么让 AI 生成的 16 张图,长着同一张脸。
二、技术拆解:把"做表情包"变成可工程化的流程
我把它拆成四个阶段,每一阶段都有明确的输入输出,可以当流水线跑:
Phase 0 需求建模 → brief
Phase 1 IP 锁版 → 锁版稿 + 角色 DNA
Phase 2 分镜 → 16 张场景稿
Phase 3 动效 → 16 个 GIF
Phase 4 提交包 → 预览/横幅/图标/头像 + 文案
2.1 IP 一致性:锁版稿 + 图生图
AI 出图最大的痛是角色漂移——尤其是头上那根标志性呆毛,每次生成形状都不一样。我的解法是:
- 先生成一张锁版稿(正面、全身、高保真)作为所有后续调用的
input_image; - 后续全部走图生图(
input_fidelity: high)而非文生图; - 在提示词里强制锁定特征,例如那根呆毛要写
permanently on top, same shape every time,否则 AI 会"自作主张"。
这一步是后面所有一致性的地基,省掉了 80% 的返工。
2.2 动效流水线:ImageGen 出帧 + Pillow 拼 GIF
出图用 ImageGen,拼图用 Pillow。下面这几个坑,是这篇文章最想讲的部分。
坑 1:Windows + Git Bash 的路径解析
在 Windows 的 Git Bash 里,如果你传 /f/work/...,ImageGen 会把它误解析成 F:\f\work\...,于是找不到文件、静默失败。
解法:一律用 F:/work/...(盘符 + 正斜杠),不要写 /f/...。
# ❌ 错
/f/workBuddy/表情包/_final/01_png/scene08/frame1.png
# ✅ 对
F:/workBuddy/表情包/_final/01_png/scene08/frame1.png
坑 2:并行调用的隐式冲突
早期我为了快,对同一场景并行发 ImageGen 请求,结果出现两种诡异丢帧:
- 多个任务的
output_dir互相覆盖; - 同毫秒生成的时间戳文件名互吞,丢了一张关键帧。
解法:严格串行调用,且每场景独立目录。慢一点,但零丢帧。
另外遇到过 RequestLimitExceeded.JobNumExceed(服务端并发上限),重试 1 次即可恢复,不用慌。
坑 3:Pillow 量化 RGBA 会报错
拼 GIF 时要量化降色省体积,但 RGBA 图用 MEDIANCUT 会直接抛 ValueError。
# ❌ RGBA 下会报错
img.save(out, "GIF", optimize=True, quantize=Image.Quantize.MEDIANCUT)
# ✅ 用 FASTOCTREE
img.save(out, "GIF", optimize=True, quantize=Image.Quantize.FASTOCTREE)
坑 4:AI 漏掉的"灰底雾"
AI 生成的图经常带一层浅灰雾,普通的"白底转透明"清不掉——因为那不是纯白,是 (234,234,231) 这种近灰。提交到平台后背景会脏。
解法:写个 clean_bg,把"亮且低饱和"的像素转透明。鸭身的黄、橙嘴、黑描边因为饱和度高,天然不会被误杀:
def clean_bg(im, thr=180, sat=20):
"""亮度>thr 且 饱和度<sat 的像素 → 透明(吃掉 AI 灰底雾)"""
data = list(im.getdata())
out = []
for r, g, b, a in data:
if a and max(r, g, b) > thr and (max(r, g, b) - min(r, g, b)) < sat:
out.append((r, g, b, 0))
else:
out.append((r, g, b, a))
im.putdata(out)
return im
2.3 平台提交包:体积预算与圆形裁切
体积:palette-256 + 最高压缩
微信表情平台对提交图有硬体积限制(横幅 ≤ 80KB、头像 ≤ 500KB)。真彩色 PNG 动辄 130KB+,超限。
im = im.convert("P", palette=Image.ADAPTIVE, colors=256)
im.save(out, "PNG", optimize=True, compress_level=9)
256 色对渐变和角色贴纸颜色影响极小(肉眼基本无 banding),体积却能压到 1/3。横幅从 136KB → 45KB,头像 28KB,全部达标。
头像:inscribed circle 安全区
微信会把头像裁成圆。如果文字或关键元素贴边,裁完就被切掉一半。
解法:所有关键内容压在 inscribed circle 内,半径 ≈ 0.47 × 边长。圆外可以放纯背景色,丢了也无伤大雅。
一个反直觉的查看器 bug
部分聊天/预览工具会把"近白色"像素渲染成棋盘格。我一度以为是图坏了,用 Pillow 逐点采样才发现像素全是实色——纯属查看器渲染抖动。
验证方法(别被眼睛骗):
from PIL import Image
im = Image.open("preview.png").convert("RGB")
print(im.getpixel((130, 8))) # 应该输出一个实色元组,而非透明
缓解:提交图一律用饱和色(琥珀/橙),别用近白奶油——饱和度高的背景在任何场景都干净。
三、沉淀:把流程封装成可复用 Skill
做完一遍后我意识到,这套流程的"知识"全在那些坑里,不沉淀就白踩了。于是我用 skill-creator 把它固化成了一个可安装的 Skill:
sticker-pack-creator/
├── SKILL.md # 四阶段工作流(Agent 自动加载)
├── README.md # 安装/使用/注意事项
├── scripts/
│ ├── make_gif.py # 参数化 GIF 拼装器(含 clean_bg + FASTOCTREE)
│ └── build_submission.py # 提交包生成器(预览/横幅/图标/头像)
└── references/
├── pitfalls.md # 所有坑:根因 + 修复 + 代码
├── wechat_specs.md # 平台规格/体积/合规表
└── character_dna_template.md # 锁版稿 manifest 模板
设计上有几点体会:
- 静态提交图不要重新 AI 生成,而是用 Pillow 拼真实 GIF 帧——保证角色 100% 一致,绝不会跑偏。
- 文档和脚本分离:
SKILL.md讲流程,references/讲坑,scripts/给能直接跑的代码。换一个 Agent 装上,照着走就能复现,不用重新 debug。 - 打包有个隐藏坑:官方的
package_skill.py如果输出目录设在 skill 内部,会把刚生成的 zip 递归塞回 zip 自己(self-nested)。输出目录一定要放到 skill 外部。
四、写在最后
整套流程跑下来,最终交付是 16 张达标 GIF(全 ≤ 500KB、总 832KB)+ 4 张规范静态图 + 完整文案,可以直接投到微信表情开放平台。
顺手产出的那套表情包叫**「鸭摆摆」**——一只头顶弹簧呆毛的黄色小鸭,专讲大学生摆烂日常,三个出圈位(灵魂飞离 / 摊平 puddle / 卷王光束)动效拉满。如果你也在做同类内容,上面那套 Skill 已经打包成可安装分发包,装上去就能复用整条流水线。
做表情包这件事,难的从来不是"画一张图",而是让 16 张图长着同一张脸、还能塞进平台的每一个体积和尺寸限制里。工程化之后,它就成了可复制的事。
附:关键参数速查
| 资产 | 尺寸 | 上限 | 关键技巧 |
|---|---|---|---|
| 单张表情 | 240×240 | ≤ 500KB | FASTOCTREE 量化 |
| 主页横幅 | 750×400 | ≤ 80KB | palette-256 + compress_level=9 |
| 图标 | 50×50 / 240×240 | — | 缩放面板图 |
| 头像 | 640×640 | ≤ 500KB | inscribed circle 安全区 |