用图像生成API逐帧绘制动画GIF——零基础也能做的AI表情包生产术 我最近把玩一个图像模型的 API 玩出了新花样用社区昵称叫 Nano Banana 的模型一帧一帧地生成定格动画再合成 GIF专门用来做表情包。这个思路把AI 生成视频这件一直很玄学的事降维成了AI 逐帧绘制翻页动画这种更可控的形式。整套代码我已经整理成了开源小工具依赖少、流程短适合对绘图 API 有一点基础、想自己做动态素材的人也可以直接抄去生成两帧眨眼、点头这类循环小动画。先说清楚一个反直觉的结论表情包本质上就是微型定格动画。一个在聊天框里无限循环的 GIF通常只有 5 到 10 帧每帧之间变化很小靠循环播放形成动起来的错觉。这和几十年前手工定格动画的制作原理一模一样——摆一下拍一张再摆一下再拍一张。区别只是把给黏土人偶摆姿势换成了用文字提示词让模型修改画面。这个认知决定了整个项目的技术路线与其指望模型一次吐出一段连贯视频不如老老实实回到逐帧生成的古老流程里让现代模型扮演那个不知疲倦的动画师。1. 为什么我不玩 AI 视频反而选择逐帧生成这种笨办法1.1 表情包的第一性原理GIF 就是微型定格动画如果你想做一个猫逐渐笑出来的表情包你真正需要的画面其实是这样的第一帧是平静的猫第二帧嘴角微微上翘第三帧咧嘴第四帧眼睛弯成月牙。把四张图按 120 毫秒一帧循环播放观感上就是一只猫在慢慢露出笑容。这个结构决定了制作工具必须满足三个条件能精确控制每一帧的画面内容能让相邻帧之间保持角色看起来是同一个人/同一只猫能轻松输出小尺寸、低帧率的循环图片。我挨个试过几类视频生成工具发现它们做表情包的体验让人头大。1.2 用 AI 视频工具做表情包体验一言难尽第一个痛点是时长不可控。视频模型默认输出几秒的片段而一个表情包通常只需要 1 秒左右的循环我需要从完整视频里截取一段能首尾相接的循环这个过程又麻烦又看运气经常剪出来的循环节在接缝处有明显跳变。第二个痛点是角色一致性不稳定。早期画面里的角色和后几秒的角色很容易出现换了张脸的情况表情包才播到第二遍脸就已经不是那张脸了这种素材根本没法用。第三个痛点是可控性差。我想让猫在第三帧眨眼、第五帧咧嘴视频模型完全没有按帧精确编辑的入口它能做的是整体生成不是局部修改。我试过在提示词里描述详细时间线结果基本等于碰运气。所以结论很直接视频生成模型适合内容创作不适合表情包制作。表情包需要的是对每一帧的绝对控制权这恰恰是逐帧生成工作流的天然优势。1.3 Nano Banana 为什么能接下这个活Nano Banana 能在这个场景里跑通靠的是它的基于参考图编辑能力用户给一张图加一句修改指令它输出一张在保留原图主体特征的前提下、按指令改变局部内容的新图。这正好匹配逐帧动画的生产逻辑——把上一帧当成参考图让模型只做小幅修改。我实测下来这种小步编辑的生成方式角色一致性比每次从头画一张新图高一个量级。打个比方如果你让一个画家每次都凭空画一只猫十张画里可能只有两张像同一只猫但如果你给画家看上一张画告诉它只把嘴角往上提一点它照着改出来的下一张几乎肯定还是那只猫。另一个附带好处是工程简单。整个生成链路不需要本地显卡不需要部署扩散模型一个普通 Python 脚本循环调用接口就够了。这也让我最终可以把它打包成一个轻量开源工具而不是一个重型的生成平台。2. 让角色不漂移的工程方案帧间编辑与提示词约束2.1 核心思路每一帧都基于上一帧做编辑具体流程是这样先用一句描述加一张空白画布生成角色的第一帧然后把第一帧作为参考图输入保持角色和背景不变只把嘴角上扬的指令得到第二帧再把第二帧作为参考图继续第三步。这个链路本质上是一个自回归的图像序列生成器。这招为什么有效因为模型每次只需要回答画面比刚才变化了多少而不是回答画面应该长什么样。人类的视觉感知也是这个原理你之所以觉得动画连续恰恰是因为相邻帧的差异小到可以被大脑自动补全。模型在生成时也一样给定上一帧做锚点之后它不会天马行空地重新想象整个场景而是老老实实在已有画面的基础上做局部修正角色漂移的概率因此大幅下降。2.2 提示词模板把动作变化翻译成模型能听懂的语言我踩了不少提示词的坑之后总结出一套固定套路把提示词拆成四个部分。第一是角色外观描述必须具体到毛色、花纹、颜色、眼睛形状、画风例如一只圆脸的橘色条纹猫白色肚皮黑色圆眼睛整体是卡通贴纸风格。第二是背景与画风描述明确写背景保持淡蓝色纯色镜头不动构图不变。第三是变化约束写清只改变表情不改变面部轮廓、毛色、位置。第四是目标动作例如嘴角微微上扬眼睛稍稍眯起。每一帧的提示词都由这四部分拼接而成前三部分逐字重复只有第四部分变化。我试过偷懒只写还是这只猫让它笑一下结果第 3 帧开始猫的条纹就莫名其妙少了两根可见外观描述重复得越完整角色漂移的概率越低。2.3 动作步长别让模型一步跨太大这一步是整个项目里最需要经验的地方。相邻两帧之间的动作差异我建议控制在能明显感觉到变化但不至于让人物姿态彻底改变的范围内。比如想做一个大笑的动作不要直接写放声大笑头后仰而应该拆成三次微笑、咧嘴、笑出声。每次只增加一点幅度。道理和传统动画补中间帧完全一样——中间帧越多过渡越顺。代价是 API 调用次数变多成本变高。我的经验值是一个完整的表情变化动作拆成 6 到 10 帧比较舒服既不会太僵硬也不会贵到肉疼。少于 6 帧会显得跳多于 10 帧边际收益就开始下降画面流畅度提升不明显成本却线性上涨。这里的边界条件也值得说两句。往上采样、细化提示词和降低帧间隔之所以有效是因为模型有记忆窗口限制参考图在请求里经过压缩之后细节锚点会被削弱。所以不要让模型承担过多的记忆任务它只需要关注这张图怎么变化就够了。剩下的流畅性交给帧数和播放速度来补。3. 完整代码从一段提示词到一张动图3.1 环境准备项目依赖非常克制Python 3.10 以上即可需要安装两个库一个用来发 HTTP 请求一个用来处理图像和合成 GIF。命令如下pip install requests pillow然后是密钥管理。我强烈建议把 API 密钥写进环境变量不要硬编码在脚本里。在 Linux 或 macOS 下用export NANO_BANANA_API_KEY你的密钥Windows 下在系统环境变量里配成同名变量即可。仓库里我会放一个.env.example文件说明变量名但不会提交任何真实密钥。3.2 帧生成主循环核心函数很简单读入上一帧图片转成 base64连同提示词一起以多模态请求体的形式发给接口再把返回的图片字节解出来。import base64 import os import time import requests API_KEY os.environ.get(NANO_BANANA_API_KEY) API_URL 官方图像编辑接口地址 def edit_image(prompt: str, input_image_path: str) - bytes: with open(input_image_path, rb) as f: img_b64 base64.b64encode(f.read()).decode() payload { contents: [ { parts: [ {text: prompt}, {inline_data: {mime_type: image/png, data: img_b64}}, ] } ] } headers {Content-Type: application/json} resp requests.post( f{API_URL}?key{API_KEY}, jsonpayload, headersheaders, timeout120, ) resp.raise_for_status() data resp.json() b64_data data[candidates][0][content][parts][0][inline_data][data] return base64.b64decode(b64_data)这个函数里最容易踩的坑是超时。生成一张图有时候会超过 30 秒requests 默认没有超时但长时间挂起会让脚本看起来像死掉了所以我显式设了 120 秒。另外响应解析路径要按实际接口文档调整不同版本响应的字段结构可能略有差异。有了这个基础函数主循环就直白了把上一帧的路径传给函数把返回的字节存成新图片再把新图片路径更新为下一轮的参考图。循环结束所有帧就位。from PIL import Image character_desc 一只圆脸的橘色条纹猫白色肚皮黑色圆眼睛卡通贴纸风格。背景是淡蓝色纯色。镜头不动构图不变只改变表情。 actions [ 表情平静眼睛正常大小, 嘴角微微上扬眼睛稍微眯起, 咧嘴笑眼睛弯成月牙, 大笑头微微后仰, 恢复平静但嘴角仍带笑意, ] current_img assets/char_base.png frames [] for i, action in enumerate(actions): prompt ( character_desc 不改变面部轮廓、毛色和位置画面中不要出现任何文字。 当前动作 action ) out_path fframes/frame_{i:02d}.png img_bytes edit_image(prompt, current_img) with open(out_path, wb) as f: f.write(img_bytes) frames.append(out_path) current_img out_path time.sleep(1) # 温柔一点避免瞬间打满并发配额这里 sleep 的 1 秒非常重要。我实测过连续快速请求会在十几帧之后触发限流一旦触发后续请求会随机失败重试逻辑再怎么写都有浪费。提前主动降速反而总体耗时更短。3.3 合成 GIF帧序列生成完毕剩下的事情交给 Pillow。核心参数只有三个save_allTrue表示保存为动画图duration控制每帧停留毫秒loop0表示无限循环。image_list [Image.open(path).convert(RGB) for path in frames] image_list[0].save( emote.gif, save_allTrue, append_imagesimage_list[1:], duration120, loop0, optimizeTrue, )表情包的常用帧率是每秒 8 到 10 帧也就是单帧停留 100 到 125 毫秒。这个帧率比视频低很多但恰好符合聊天框里的观看节奏太快了看不清表情太慢了显得拖沓。optimizeTrue会让 Pillow 尝试做帧间优化对压缩体积有帮助。如果输出文件还是太大可以把图像先转成调色板模式再保存颜色数限制在 128 或 256。卡通风格的表情包本身颜色数量有限转调色板几乎看不出画质损失体积却能明显下降。3.4 表情包专用后处理压缩、字幕、循环给 GIF 加字幕有一个特别容易犯的错只给第一帧画文字。这样字幕会在播放到第二帧时消失闪一下就没效果非常奇怪。正确做法是先把所有帧都加上相同的文字再重新合成 GIF。from PIL import Image, ImageDraw, ImageFont font_path path/to/your/font.ttf # 换成系统里可用的中文字体路径 def add_caption(frame_paths, caption_text, target_size(480, 480)): labeled [] for path in frame_paths: img Image.open(path).convert(RGB).resize(target_size, Image.LANCZOS) draw ImageDraw.Draw(img) draw.text( (16, target_size[1] - 52), caption_text, fill(255, 255, 255), fontImageFont.truetype(font_path, 28), stroke_width3, stroke_fill(0, 0, 0), ) labeled.append(img) return labeled labeled_frames add_caption(frames, ) labeled_frames[0].save( emote_with_caption.gif, save_allTrue, append_imageslabeled_frames[1:], duration120, loop0, optimizeTrue, )中文字体会因为系统不同而路径不同这是脚本里最需要本地调整的一处。如果找不到中文字体字幕会变成方块建议在 README 里写明如何安装或指定系统字体路径。描边宽度我控制在 3 到 4 像素太粗会盖住角色的脸太细在浅色背景下又看不清。字幕文字的像素风格、字号大小也直接影响表情包的质感。我的习惯是正文 28 到 32 像素用粗体白字黑描边放在画面底部偏左的位置避免盖住角色下巴。实测下来这套参数在多数表情包场景里都成立。4. 实测效果与翻车现场4.1 表现最好的三类场景我跑了二十多组不同动作的生成测试发现三类场景的成功率明显高于其他。第一类是整体旋转平移动作比如点头、摇头、耸肩。这类动作不涉及局部形态的大改模型只需要把整个头部绕着一个轴轻微转动成功率最高。第二类是表情变化高兴、难过、惊讶这类情绪切换只要每步幅度控制好出来的效果非常生动。第三类是头顶冒东西的魔法效果比如凭空出现一个问号、叹气时飘出一团白气。这类场景的背景和角色保持不变变化的只是额外元素模型也不容易翻车。它们的共同特点是形状变化不大主要是姿态、表情或附加元素的改变。也就是说模型擅长的是适度修改而非彻底重构顺着这个特性选题材翻车率自然低。4.2 高频翻车清单与修复办法翻车现场比成功案例更有参考价值我按出现频率整理了一个问题清单。现象根因分析修复方案第 4 帧开始颜色整体变淡参考图在请求过程中被压缩细节锚点被削弱保存帧时统一用 PNG提示词加上保持原有颜色饱和度和亮度猫的条纹少了一根角色外观描述被简写模型自由发挥每一帧都重复完整外观描述把动作步长拆得更细背景从纯色渐变成渐变天空背景描述太笼统模型脑补了环境把背景描述写死为淡蓝色纯色背景不要给模型发挥空间画面里突然出现一行文字模型把提示词里的局部文字渲染出来了在提示词末尾固定加画面中不要出现任何文字连续请求到第 10 帧开始报错触发并发或频率限制主循环里加 sleep重试时用指数退避而不是立刻硬刚表格里第五个问题在开发时最容易忽略。我一开始以为限流只会在大量并发时出现实际上单人连续快速请求也会触发。后来我在循环里加了 1 秒的 sleep并且把请求封装成带三次重试的函数才稳定跑完完整长序列。4.3 成本、速度与上限数据成本是绕不开的话题。这个模型的 API 按 token 计费文本和图像 token 分别计算具体价格需要去官方文档核对。我这边跑下来的体感是一个 480x480 的 10 帧表情包累计消耗在几美分量级作为个人娱乐和素材试验完全能接受但如果要批量生产几千个表情包成本就得认真算了。单帧速度方面从提交到返回短则 5 秒、长则 20 秒取决于画面复杂度和输出大小。一个 10 帧的表情包包括生成和合成全程大约 3 到 5 分钟。这速度跟实时渲染没法比但做表情包本来就是慢工出细活我通常挂着脚本去干别的事回来时素材已经躺在 output 目录里了。还有一点要注意的是配额和并发上限免费或试用额度的限制通常很苛刻。我的建议是先在脚本里把每次请求之间的最小间隔设为 1 秒把最大并发设为 1跑通全流程之后再考虑提速。不要一上来就开多线程脚本能稳定跑完比能跑得快重要得多。5. 开源后的配置细节与扩展方向5.1 仓库里不该出现的东西开源项目第一件要做的事就是把密钥排除在版本管理之外。几十行的小工具里写死密钥很常见但一旦推到远端仓库密钥就等于裸奔。我在仓库里放了.gitignore明确忽略.env文件和任何包含密钥的本地配置同时写了一个.env.example说明需要配置哪些变量。另一个容易被忽略的点是输出目录的清理。生成的帧和中间产物体积不小不适合全部提交到仓库。我只提交了少量示例产物其余路径都加进.gitignore保证别人克隆下来之后仓库体积还控制在几兆以内。5.2 仓库里值得放的三样东西如果只是丢一个脚本上去别人很难复现这不是一个负责任的开源项目。我整理了三个必放内容。第一prompts.md示例文件。把我前面总结的四段式提示词模板完整写进去再附上三个成功案例的完整提示词和对应动作列表让使用者能直接照抄修改。第二demo_frames/目录。放一组已经生成的帧序列和最后合成的 GIF这样别人不用花一分钱就能先看到效果决定要不要继续折腾。第三一个低速模式运行脚本。把请求间隔、重试次数都调到最保守状态保证新用户第一次运行就能成功而不是上来就撞进限流。5.3 这个思路还能继续做什么逐帧生成动画这条路线一旦跑通能做的事情比表情包多得多。两帧眨眼动画是最轻量的变体给定角色正面头像让它在两帧之间切换睁眼和闭眼循环播放就是一个活灵活现的活角色。Loading 动画也是个好方向一个圆环、一张卡片、一段进度条每帧变一点生成 8 到 12 帧就是可以贴进网页的轻量动效。还有系列表情包同一个角色保持外观描述完全一致换不同的动作脚本批量产出一整套风格统一的表情这是 AI 视频工具很难做到的。技术上也有两个值得发力的方向。一是中间帧插值先用 6 帧做出关键动作再用传统插值算法把每两帧之间补齐让动画从能看到帧跳变成丝般顺滑成本却能省下一大截。二是导出格式的扩展GIF 方便但在体积上吃亏这套帧序列流程完全可以再对齐成 WebP 动态图或 APNG 输出体积更小、画质更好就看目标平台怎么取舍了。我自己的下一步打算是把这套流程封装成一个带命令行参数的脚本输入动作列表的 JSON 文件就直接产出成品 GIF这样以后再做表情包连 Python 代码都不用打开改 JSON 就行。如果你也试过用逐帧生成做动图大概率会遇到角色漂移的问题回头看看提示词模板是不是把外观描述写得太随意了——我踩过几次坑之后才明白模型能帮你省下摆姿势的力气但角色长什么样这件事还是要你替它记清楚的。