# animated-sticker-studio

Adopt this skill: https://proskills.md/skills/other/fantuan-lab-animated-sticker-studio

Page: https://proskills.md/skills/other/fantuan-lab-animated-sticker-studio
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: SKILL.md
size: 5513
sha: 8c98d5b325c683888812f86392cd1b90cc0e41d7
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/SKILL.md

````text
---
name: animated-sticker-studio
description: Turn a character image (mascot, OC, pet photo, 3D render, or just a text description) into a full pack of animated chat stickers — real keyframe animation plus motion, captions and effects — exported to the exact spec of WeChat 表情开放平台, Telegram, LINE, Discord, Slack, WhatsApp or plain GIF, auto-compressed under each size limit, visually verified and zipped. Use for 表情包/动态表情/贴纸/sticker pack requests of any size (8 to 100+), for redoing single stickers, or for converting an existing pack to another platform.
---

# Animated Sticker Studio · 动态表情包工坊

Pipeline: **plan → reference → atlases → cut → build → look → fix → deliver**. Scripts live in `scripts/` (Python 3 + Pillow; ffmpeg for Telegram/MP4). Run every script with `--help` before first use.

## 1. Plan (manifest.json)

Ask only what you cannot default. Defaults: 16 stickers, Simplified Chinese captions, `generic` GIF; add `wechat` if they mention 微信表情开放平台/上架, `telegram`/`line`/`discord`/`slack`/`whatsapp` if named. WeChat albums take any 8–24 stickers; LINE packs must be 8, 16 or 24.

Write `manifest.json` next to the work (format: [references/manifest.md](references/manifest.md)). Pick captions from [references/phrases.md](references/phrases.md) unless the user gave their own, and give every entry:

- `pose` — what the body does, in English, concrete enough to draw (arms, props, expression). Two entries may never share the same pose with only new text.
- `motion` + `icon` + optional `caption_fx` — chosen to match the line (table in phrases.md).
- For the 4–8 stickers where the gesture IS the joke (挥手, 鼓掌, 打字, 干饭, 点赞, 哭, 打哈欠, 比心…) put them in a group with `"keys": 4` and write four `key_poses`. These become real frame-by-frame animation. Single poses get procedural motion only.

Show the user the plan as a short table (编号 / 文案 / 动作) before spending on generation when the pack is ≥24 or the user seems picky; otherwise go.

## 2. Reference

- User image → copy into `ref/`, set `character.reference`. Write `character.anchors`: one English sentence listing what must never change (shape, colours, markings, accessories).
- 3D model → render a front/three-quarter view with the face visible, use that.
- Only a description → `gen_atlas.py manifest.json --provider hiapi --make-reference "<description>"`, show the result, get a yes.
- Use only art the user owns or may adapt. Do not recreate a third party's character from memory.

## 3. Atlases

One sheet per group (≤16 cells). Either:

- **Your own image tool** (Codex `image_gen`, etc.): `gen_atlas.py manifest.json --print-prompts`, generate each sheet with the reference attached, save as the printed path, then set the group's `atlas` and `grid`.
- **Bundled providers**: `gen_atlas.py manifest.json --provider hiapi|openai` (keys only from env: `HIAPI_API_KEY`, or `OPENAI_API_KEY` + optional `OPENAI_BASE_URL`/`OPENAI_IMAGE_MODEL`). Skips groups that already have an atlas; `--group N` redraws one. Never print or echo a key.

Open every sheet at full size. Reject and redraw (`--group N`) when the identity drifts, a cell is missing or doubled, limbs are clipped, or two poses are the same.

## 4. Cut

`cut_atlas.py manifest.json` — removes solid or fake-checkerboard backgrounds, finds each sprite by its pixels (not by a rigid grid), writes `sprites/` and fills every entry's `sprites` list. **Open each `atlases/*.index.png`** and confirm number N shows the pose written for entry N. If the model swapped cells, fix by editing `sprites` paths in the manifest — never by renaming captions to fit wrong art.

## 5. Build

```sh
python3 scripts/build_pack.py manifest.json out --mp4          # platforms from manifest
python3 scripts/build_pack.py manifest.json out --only 3,7-9   # quick re-render of a few while iterating
```

Fonts are found automatically (PingFang/Hiragino/Noto CJK/YaHei); `--font` overrides. Each platform file is re-encoded until it fits that platform's byte limit; `report.json` records the setting used.

## 6. Look (required — you cannot watch a GIF, so look at these)

- `out/review/motion-XX.png`: every sticker as 4 frames side by side on alternating white/dark rows. Check: the motion reads as the caption's emotion, keyframes don't jitter sideways, nothing clipped, no white halo on dark rows, captions legible, accents not covering the face.
- `out/review/contact.png`: whole pack at once — check variety and that no two look alike.
- The build's JSON summary: `failing` (per-platform spec failures) and `pack_issues` (near-duplicate poses, wrong pack size, stickers that barely move). Fix all of them or tell the user exactly which remain and why.

Fix loop: wrong motion/icon/caption → edit manifest, `--only`; bad art → redraw that group or cell; then rebuild everything once at the end.

## 7. Deliver

Give the user: the ZIP path, `预览.html` (opens offline; light/dark/checker backgrounds and a chat mock-up), the MP4 if made, and a one-line pass/fail per platform from the summary. Mention platform limits that still need a human: WeChat needs a 750×400 banner and copyright paperwork, LINE/Telegram need account-side upload. Specs are in [references/platforms.md](references/platforms.md); say they are as of 2026-11 and the platform console wins.

Keep the original reference and atlases out of the ZIP unless asked.
````

## File: README.md
size: 10097
sha: 02944bf804d62974a6c02ea14898eea58c98a9aa
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/README.md

````text
<p align="center">
  <img src="examples/onigiri-cat/hero.webp" width="100%" alt="Onigiri Cat animated stickers">
</p>

<p align="center">
  <b>One character image → a full pack of animated chat stickers.<br>Exported to the exact upload spec of WeChat, Telegram, LINE, Discord, Slack and WhatsApp.</b>
</p>

<p align="center">
  <a href="https://github.com/fantuan-lab/animated-sticker-studio/stargazers"><img src="https://img.shields.io/github/stars/fantuan-lab/animated-sticker-studio?style=flat&color=f5a623" alt="stars"></a>
  <a href="https://github.com/fantuan-lab/animated-sticker-studio/releases"><img src="https://img.shields.io/github/v/release/fantuan-lab/animated-sticker-studio?color=e85d75" alt="release"></a>
  <img src="https://img.shields.io/badge/Claude_Code-skill-d97757" alt="Claude Code skill">
  <img src="https://img.shields.io/badge/Codex-skill-10a37f" alt="Codex skill">
  <img src="https://img.shields.io/badge/WeChat%20%C2%B7%20Telegram%20%C2%B7%20LINE%20%C2%B7%20Discord%20%C2%B7%20Slack%20%C2%B7%20WhatsApp-6%20platforms-4c9be8" alt="WeChat, Telegram, LINE, Discord, Slack, WhatsApp">
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
</p>

<p align="center">
  <a href="README.md"><b>English</b></a> | <a href="README.zh-CN.md">中文</a>
</p>

# Animated Sticker Studio

Give your AI coding agent one character image (or just a description) and get back a full pack of chat stickers that **actually animate**: it writes the captions and poses, draws every keyframe, adds text and effects, exports to each platform's exact upload spec, compresses every file under the size limit, checks its own work, and zips it up.

Works in Claude Code, Codex, and any agent that can run Python.

## Example: Onigiri Cat · 24 stickers

An original character, made in one run: 1 character sheet + 3 pose sheets = 4 image-generation calls, no manual retouching. Shown below is the WeChat Sticker Platform export (240×240, ≤500 KB each), submitted for WeChat review on 2026-11-05.

**#1–16**: one pose each, plus procedural motion. **#17–24**: real frame-by-frame animation — the paw waves, the tears stream, the yawn opens.

| | | | | | |
|---|---|---|---|---|---|
| <img src="examples/onigiri-cat/gif/001-%E6%94%B6%E5%88%B0.gif" width="120" alt="001-收到"><br>001-收到 | <img src="examples/onigiri-cat/gif/002-%E5%A5%BD%E8%80%B6.gif" width="120" alt="002-好耶"><br>002-好耶 | <img src="examples/onigiri-cat/gif/003-%E6%8A%B1%E6%8A%B1.gif" width="120" alt="003-抱抱"><br>003-抱抱 | <img src="examples/onigiri-cat/gif/004-%E6%97%A9%E5%AE%89.gif" width="120" alt="004-早安"><br>004-早安 | <img src="examples/onigiri-cat/gif/005-%E5%9C%A8%E5%90%97.gif" width="120" alt="005-在吗"><br>005-在吗 | <img src="examples/onigiri-cat/gif/006-%E5%93%88%E5%93%88%E5%93%88%E5%93%88.gif" width="120" alt="006-哈哈哈哈"><br>006-哈哈哈哈 |
| <img src="examples/onigiri-cat/gif/007-%E5%A7%94%E5%B1%88.gif" width="120" alt="007-委屈"><br>007-委屈 | <img src="examples/onigiri-cat/gif/008-%E7%94%9F%E6%B0%94%E4%BA%86.gif" width="120" alt="008-生气了"><br>008-生气了 | <img src="examples/onigiri-cat/gif/009-%E6%88%91%E6%83%B3%E6%83%B3.gif" width="120" alt="009-我想想"><br>009-我想想 | <img src="examples/onigiri-cat/gif/010-%E5%8A%A0%E6%B2%B9.gif" width="120" alt="010-加油"><br>010-加油 | <img src="examples/onigiri-cat/gif/011-%E8%B0%A2%E8%B0%A2%E8%80%81%E6%9D%BF.gif" width="120" alt="011-谢谢老板"><br>011-谢谢老板 | <img src="examples/onigiri-cat/gif/012-%E6%BA%9C%E4%BA%86.gif" width="120" alt="012-溜了"><br>012-溜了 |
| <img src="examples/onigiri-cat/gif/013-%E9%9C%87%E6%83%8A.gif" width="120" alt="013-震惊"><br>013-震惊 | <img src="examples/onigiri-cat/gif/014-%E6%91%B8%E9%B1%BC%E4%B8%AD.gif" width="120" alt="014-摸鱼中"><br>014-摸鱼中 | <img src="examples/onigiri-cat/gif/015-%E5%A5%BD%E7%9A%84.gif" width="120" alt="015-好的"><br>015-好的 | <img src="examples/onigiri-cat/gif/016-%E5%86%B2%E9%B8%AD.gif" width="120" alt="016-冲鸭"><br>016-冲鸭 | <img src="examples/onigiri-cat/gif/017-%E6%8B%9C%E6%8B%9C.gif" width="120" alt="017-拜拜"><br>017-拜拜 | <img src="examples/onigiri-cat/gif/018-%E9%BC%93%E6%8E%8C.gif" width="120" alt="018-鼓掌"><br>018-鼓掌 |
| <img src="examples/onigiri-cat/gif/019-%E6%89%93%E5%AD%97%E4%B8%AD.gif" width="120" alt="019-打字中"><br>019-打字中 | <img src="examples/onigiri-cat/gif/020-%E5%B9%B2%E9%A5%AD.gif" width="120" alt="020-干饭"><br>020-干饭 | <img src="examples/onigiri-cat/gif/021-%E7%82%B9%E8%B5%9E.gif" width="120" alt="021-点赞"><br>021-点赞 | <img src="examples/onigiri-cat/gif/022-%E5%93%AD%E5%93%AD.gif" width="120" alt="022-哭哭"><br>022-哭哭 | <img src="examples/onigiri-cat/gif/023-%E5%9B%B0%E4%BA%86.gif" width="120" alt="023-困了"><br>023-困了 | <img src="examples/onigiri-cat/gif/024-%E6%AF%94%E5%BF%83.gif" width="120" alt="024-比心"><br>024-比心 |

<details>
<summary>The same pack exported to four platforms, all passing their spec checks</summary>

| Platform | Spec | Largest / average file | Limit |
|---|---|---|---|
| Generic GIF | 320×320 GIF | 702 KB / 578 KB | 1 MB (soft) |
| WeChat Sticker Platform | 240×240 GIF + 120 thumbnails + transparent cover and icon | 442 KB / 368 KB | 500 KB |
| Telegram video sticker | 512×512 transparent WebM VP9 | 128 KB / 94 KB | 256 KB |
| LINE animated sticker | 320×270 APNG, 12 frames, 2 loops | 298 KB / 270 KB | 300 KB |

</details>

## What it does

- **Characters really move.** Gesture stickers get 2–4 drawn keyframes (wave, clap, type, eat, cry, yawn…), layered with procedural motion. Most AI sticker tools only slide and squash one still image.
- **22 seamless motion presets**, 5 caption animations (pop, typewriter, wave, shake, static), 17 vector effect icons that stay sharp at any size.
- **Smooth illustration or pixel-art** rendering.
- **Platform presets**: generic GIF, WeChat Sticker Platform, Telegram, LINE, Discord, Slack, WhatsApp — size, format, frame count, duration and byte limits. Files are re-encoded until they fit, and the setting used is recorded.
- **Companion images** each platform asks for: WeChat thumbnails / cover / chat icon, LINE main + tab, Telegram and WhatsApp pack icons.
- **Robust cutting**: removes solid backgrounds and the fake "transparent" checkerboard image models often paint; finds each sprite by its pixels, so uneven grids still cut cleanly; writes a numbered index sheet per atlas.
- **Self-review the agent can actually see**: agents can't watch a GIF, so every sticker is laid out as 4 frames on alternating light/dark rows. Clipping, halos on dark mode, wrong motion and near-duplicate poses (perceptual hash) are flagged; stickers that jump off the canvas are auto-shrunk.
- **Offline preview page** with light / dark / checker / pixel backgrounds and a chat mock-up.
- **CJK fonts found automatically**, with a glyph-coverage check.

## Quick start

**Install**

- Claude Code: put this folder at `~/.claude/skills/animated-sticker-studio/`, or tell Claude: *"Install this skill: https://github.com/fantuan-lab/animated-sticker-studio"*
- Codex: put it at `~/.codex/skills/animated-sticker-studio/` (`agents/openai.yaml` is included).

Requires Python 3 + Pillow (`pip install Pillow`). Telegram export and the MP4 preview also need ffmpeg.

**Make a pack**

Send your agent a character image and say:

> Use animated-sticker-studio to make 16 animated stickers of this character for the WeChat Sticker Platform.

Or: "24 office-worker stickers", "use my own captions", "pixel style", "also export a Telegram version". No image? Describe the character and it will draw a reference sheet for you to approve first.

**Image generation: you only need ONE of these**

| You use | What to set up | Notes |
|---|---|---|
| Codex | Nothing | Codex has a built-in image tool; the skill hands it the prompts |
| Claude Code (recommended: hiapi) | One hiapi key as `HIAPI_API_KEY` | Used for the Onigiri Cat pack, tested. gpt-image-2, 1K transparent, about $0.04 per sheet. Sign up with my invite link: [hiapi.ai/invite/fantuan](https://hiapi.ai/invite/fantuan) |
| Claude Code (fallback: OpenAI) | Only if you already have an OpenAI or compatible-gateway key: `OPENAI_API_KEY` | Code is written but not yet tested; not needed if you use hiapi |

<details>
<summary>Run the scripts yourself</summary>

```sh
# 1. write manifest.json (format: references/manifest.md, captions: references/phrases.md)
# 2. generate pose sheets
python3 scripts/gen_atlas.py manifest.json --provider hiapi
# 3. cut sprites, then check atlases/*.index.png
python3 scripts/cut_atlas.py manifest.json
# 4. render, export, verify, zip
python3 scripts/build_pack.py manifest.json out --platform wechat,telegram --mp4
```

Rebuild the example without spending anything (the cut sprites are in the repo):

```sh
cd examples/onigiri-cat
python3 ../../scripts/build_pack.py manifest.json out --platform generic,wechat
```

</details>

## Repository layout

```
SKILL.md                workflow the agent follows
scripts/gen_atlas.py    prompts + image-generation providers
scripts/cut_atlas.py    background removal, sprite cutting, index sheets
scripts/build_pack.py   render, per-platform export, compression, checks, preview, zip
scripts/sticker_core.py motion, icons, captions, die-cut outline
references/             manifest format, platform specs, caption library (Chinese)
examples/onigiri-cat/   Onigiri Cat: reference, atlases, sprites, finished GIFs
```

## Notes

- Platform specs reflect public requirements as of November 2026; the platform's own console is the final word. Store listings (banners, copyright paperwork, account setup) are still up to you.
- Your reference image is sent to the image provider you choose. Use only characters you own or are licensed to adapt.
- Built after studying [WoYun520/pixel-gif-sticker-pack](https://github.com/WoYun520/pixel-gif-sticker-pack); the Chinese README has a point-by-point comparison.

## License

[MIT](LICENSE)
````

## File: scripts/build_pack.py
size: 37259
sha: b57388e1015bc2325390b2aa978b20c62a29ecf6
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/scripts/build_pack.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/scripts/build_pack.py

## File: scripts/cut_atlas.py
size: 11995
sha: 5d6d115e56db122984c65e8fe44f4d3965463556
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/scripts/cut_atlas.py

```text
#!/usr/bin/env python3
"""Cut generated sprite sheets into one clean transparent PNG per pose.

Two ways to call it:

  # 1) whole manifest: cuts every group's atlas and writes `sprites` back into the manifest
  python3 cut_atlas.py manifest.json

  # 2) a single sheet
  python3 cut_atlas.py sheet.png --grid 4x4 --out sprites/

What it handles that a plain grid crop does not:
  * a solid or near-solid background (white, grey, chroma green…) → removed by edge flood-fill
  * a fake "transparent" checkerboard baked into the pixels → removed too
  * uneven gutters, sprites drifting across cell lines → each blob is assigned to the cell holding its centre
  * stray specks / leftover noise → dropped
For each sheet it also writes <sheet>.index.png: every cut sprite on a checkerboard with its number,
so you can confirm the cell→caption mapping by eye before rendering.
"""
from __future__ import annotations

import argparse
import json
import sys
from collections import Counter, deque
from pathlib import Path

from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont

sys.path.insert(0, str(Path(__file__).resolve().parent))
import sticker_core as core  # noqa: E402

# ------------------------------------------------------------------ background removal

def _near(img_rgb: Image.Image, color, tol) -> Image.Image:
    """L mask: 255 where every channel is within tol of color."""
    diff = ImageChops.difference(img_rgb, Image.new("RGB", img_rgb.size, color))
    r, g, b = diff.split()
    m = ImageChops.lighter(ImageChops.lighter(r, g), b)
    return m.point(lambda v: 255 if v <= tol else 0)

def _border_colors(img_rgb: Image.Image, k=2):
    W, H = img_rgb.size
    small = img_rgb.resize((max(8, W // 4), max(8, H // 4)), Image.Resampling.NEAREST)
    w, h = small.size
    px = small.load()
    cnt = Counter()
    for x in range(w):
        cnt[tuple(v // 8 * 8 for v in px[x, 0])] += 1
        cnt[tuple(v // 8 * 8 for v in px[x, h - 1])] += 1
    for y in range(h):
        cnt[tuple(v // 8 * 8 for v in px[0, y])] += 1
        cnt[tuple(v // 8 * 8 for v in px[w - 1, y])] += 1
    total = sum(cnt.values())
    common = cnt.most_common(k)
    return [(tuple(c + 4 for c in col), n / total) for col, n in common]

def _flood_from_edges(mask: Image.Image) -> Image.Image:
    """Keep only the parts of a 255-mask that touch the image border (true background)."""
    W, H = mask.size
    m = mask.convert("L")
    seed_val, fill_val = 255, 128
    rgb = Image.merge("RGB", (m, m, m))
    d = ImageDraw.Draw(rgb)
    px = rgb.load()
    step = max(1, min(W, H) // 64)
    seeds = [(x, 0) for x in range(0, W, step)] + [(x, H - 1) for x in range(0, W, step)] + \
            [(0, y) for y in range(0, H, step)] + [(W - 1, y) for y in range(0, H, step)]
    for s in seeds:
        if px[s][0] == seed_val:
            ImageDraw.floodfill(rgb, s, (fill_val,) * 3, thresh=0)
    return rgb.getchannel("R").point(lambda v: 255 if v == fill_val else 0)

def remove_background(img: Image.Image, tol=28) -> tuple[Image.Image, str]:
    img = img.convert("RGBA")
    a = img.getchannel("A")
    lo, _ = a.getextrema()
    if lo < 200:
        # already has real transparency; just drop faint haze
        img.putalpha(a.point(lambda v: 0 if v < 24 else v))
        return img, "已有透明背景"
    rgb = img.convert("RGB")
    cols = _border_colors(rgb, 2)
    (c1, f1), (c2, f2) = cols[0], cols[1] if len(cols) > 1 else (cols[0][0], 0)
    checker = f2 > 0.2 and sum(abs(x - y) for x, y in zip(c1, c2)) > 12
    if checker:
        bgmask = ImageChops.lighter(_near(rgb, c1, 14), _near(rgb, c2, 14))
        how = f"去掉了假透明格子背景 {c1}/{c2}"
    else:
        bgmask = _near(rgb, c1, tol)
        how = f"去掉了纯色背景 {c1}"
    bg = _flood_from_edges(bgmask)
    # close pinholes inside the background, then feather the edge by one pixel
    bg = bg.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.MinFilter(3))
    alpha = ImageChops.invert(bg).filter(ImageFilter.GaussianBlur(0.6))
    img.putalpha(ImageChops.multiply(alpha, a))
    return img, how

# ------------------------------------------------------------------ components

def components(alpha: Image.Image, scale: int, min_frac=0.0006):
    """Connected blobs on a downscaled alpha. Returns list of (bbox_fullres, area_px)."""
    W, H = alpha.size
    w, h = max(1, W // scale), max(1, H // scale)
    small = alpha.resize((w, h), Image.Resampling.BOX).point(lambda v: 1 if v > 40 else 0)
    data = small.load()
    seen = bytearray(w * h)
    out = []
    for y in range(h):
        for x in range(w):
            if not data[x, y] or seen[y * w + x]:
                continue
            q = deque([(x, y)])
            seen[y * w + x] = 1
            x0 = x1 = x
            y0 = y1 = y
            area = 0
            while q:
                cx, cy = q.popleft()
                area += 1
                x0, x1, y0, y1 = min(x0, cx), max(x1, cx), min(y0, cy), max(y1, cy)
                for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)):
                    if 0 <= nx < w and 0 <= ny < h and data[nx, ny] and not seen[ny * w + nx]:
                        seen[ny * w + nx] = 1
                        q.append((nx, ny))
            if area >= min_frac * w * h:
                out.append(((x0 * scale, y0 * scale, (x1 + 1) * scale, (y1 + 1) * scale), area))
    return out

def cut_sheet(path: Path, cols: int, rows: int, out_dir: Path, prefix: str, tol=28):
    img, how = remove_background(Image.open(path), tol)
    W, H = img.size
    alpha = img.getchannel("A")
    blobs = components(alpha, scale=max(1, min(W, H) // 384))
    cw, ch = W / cols, H / rows
    cells = {(c, r): [] for r in range(rows) for c in range(cols)}
    warnings = []
    for (x0, y0, x1, y1), area in blobs:
        cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
        c, r = min(cols - 1, int(cx // cw)), min(rows - 1, int(cy // ch))
        cells[(c, r)].append(((x0, y0, x1, y1), area))
    out_dir.mkdir(parents=True, exist_ok=True)
    results = []
    for r in range(rows):
        for c in range(cols):
            parts = cells[(c, r)]
            idx = r * cols + c
            if not parts:
                warnings.append(f"格子 {idx + 1}（第 {r + 1} 行第 {c + 1} 列）是空的")
                results.append(None)
                continue
            biggest = max(a for _, a in parts)
            parts = [p for p in parts if p[1] >= biggest * 0.02]  # drop specks next to the main body
            x0 = min(p[0][0] for p in parts)
            y0 = min(p[0][1] for p in parts)
            x1 = max(p[0][2] for p in parts)
            y1 = max(p[0][3] for p in parts)
            pad = 4
            box = (max(0, x0 - pad), max(0, y0 - pad), min(W, x1 + pad), min(H, y1 + pad))
            # only keep pixels of this cell's blobs (neighbours that overlap the box get masked out)
            mask = Image.new("L", (W, H))
            md = ImageDraw.Draw(mask)
            for (bx0, by0, bx1, by1), _ in parts:
                md.rectangle((bx0 - pad, by0 - pad, bx1 + pad, by1 + pad), fill=255)
            sprite = img.copy()
            sprite.putalpha(ImageChops.multiply(alpha, mask))
            sprite = sprite.crop(box)
            bb = sprite.getchannel("A").getbbox()
            sprite = sprite.crop(bb)
            if box[0] <= 0 or box[1] <= 0 or box[2] >= W or box[3] >= H:
                warnings.append(f"格子 {idx + 1} 碰到图片边缘，可能被画布截断")
            if x1 - x0 > cw * 1.15 or y1 - y0 > ch * 1.15:
                warnings.append(f"格子 {idx + 1} 比格子还大，可能和邻居粘在一起了")
            p = out_dir / f"{prefix}_{idx + 1:02d}.png"
            sprite.save(p)
            results.append(p)
    extra = sum(len(v) for v in cells.values()) - len([x for x in results if x])
    index_sheet(results, cols, rows, path.with_suffix(".index.png"))
    return results, how, warnings

def index_sheet(paths, cols, rows, dest: Path):
    tile = 220
    im = Image.new("RGB", (cols * tile, rows * tile), (236, 236, 236))
    d = ImageDraw.Draw(im)
    for y in range(0, rows * tile, 14):
        for x in range(0, cols * tile, 14):
            if (x // 14 + y // 14) % 2:
                d.rectangle((x, y, x + 13, y + 13), fill=(210, 210, 210))
    try:
        p, i = core.find_font(["0123456789"])
        f = core.ImageFont.truetype(p, 26, index=i)
    except SystemExit:
        f = ImageFont.load_default()
    for k, path in enumerate(paths):
        x, y = (k % cols) * tile, (k // cols) * tile
        if path:
            s = Image.open(path)
            s.thumbnail((tile - 30, tile - 30))
            im.paste(s, (x + (tile - s.width) // 2, y + 24 + (tile - 30 - s.height) // 2), s)
        d.rectangle((x, y, x + tile - 1, y + tile - 1), outline=(150, 150, 150))
        d.text((x + 8, y + 4), str(k + 1), font=f, fill=(200, 30, 60))
    im.save(dest)

# ------------------------------------------------------------------ manifest mode

def run_manifest(mpath: Path, tol):
    m = json.loads(mpath.read_text(encoding="utf-8"))
    base = mpath.parent
    report = []
    for gi, g in enumerate(m["groups"], 1):
        atlases = g.get("atlas")
        if not atlases:
            continue
        atlases = atlases if isinstance(atlases, list) else [atlases]
        cols, rows = g.get("grid", [4, 4])
        keys = int(g.get("keys", 1))
        cut = []
        for ai, a in enumerate(atlases):
            ap = base / a
            if not ap.exists():
                report.append(f"组 {g['name']}：还没有 {a}")
                continue
            prefix = f"g{gi:02d}" + (f"-{ai + 1}" if len(atlases) > 1 else "")
            sprites, how, warns = cut_sheet(ap, cols, rows, base / "sprites", prefix, tol)
            report.append(f"组 {g['name']} {a}：{how}；切出 {len([s for s in sprites if s])}/{cols * rows}")
            report += [f"  ⚠ {w}" for w in warns]
            report.append(f"  对照图：{ap.with_suffix('.index.png')}")
            cut += sprites
        # keys>1: each row of an atlas = one sticker's keyframes, left→right
        entries = g["entries"]
        need = len(entries) * keys
        if len(cut) < need:
            report.append(f"  ⚠ 组 {g['name']} 需要 {need} 个格子，只有 {len(cut)} 个")
        for i, e in enumerate(entries):
            cells = cut[i * keys:(i + 1) * keys]
            if not cells or any(c is None for c in cells):
                report.append(f"  ⚠ 「{e.get('text')}」缺图，先重画这一格")
                e.pop("sprites", None)
                continue
            e["sprites"] = [str(c.relative_to(base)) for c in cells]
    mpath.write_text(json.dumps(m, ensure_ascii=False, indent=1), encoding="utf-8")
    print("\n".join(report))
    print(f"已把 sprites 写回 {mpath}。先看每张 *.index.png，确认编号和文案对得上。")

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("input", type=Path, help="manifest.json 或者一张图集 PNG")
    ap.add_argument("--grid", default="4x4", help="单张图集模式：列x行")
    ap.add_argument("--out", type=Path, default=Path("sprites"))
    ap.add_argument("--prefix", default=None)
    ap.add_argument("--tol", type=int, default=28, help="纯色背景的容差，背景没去干净就调大")
    a = ap.parse_args()
    if a.input.suffix.lower() == ".json":
        run_manifest(a.input, a.tol)
        return
    cols, rows = (int(v) for v in a.grid.lower().split("x"))
    sprites, how, warns = cut_sheet(a.input, cols, rows, a.out, a.prefix or a.input.stem, a.tol)
    print(how)
    for w in warns:
        print("⚠", w)
    print(f"切出 {len([s for s in sprites if s])}/{cols * rows} → {a.out}；对照图 {a.input.with_suffix('.index.png')}")

if __name__ == "__main__":
    main()
```

## File: scripts/gen_atlas.py
size: 13383
sha: b7d77554550f9e4913f8feed18f5e661185f7701
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/scripts/gen_atlas.py

```text
#!/usr/bin/env python3
"""Generate pose sheets (atlases) for every group in a sticker manifest.

  python3 gen_atlas.py manifest.json --provider hiapi            # all groups without an atlas yet
  python3 gen_atlas.py manifest.json --provider hiapi --group 2  # (re)generate one group
  python3 gen_atlas.py manifest.json --print-prompts             # just print prompts for an agent's own image tool
  python3 gen_atlas.py manifest.json --provider hiapi --make-reference "a round cream hamster mascot..."

Providers (keys come from the environment only, never from arguments):
  hiapi   HIAPI_API_KEY        gpt-image-2 image-to-image via https://api.hiapi.ai/v1/tasks (data-URI reference)
  openai  OPENAI_API_KEY       /v1/images/edits on OPENAI_BASE_URL (default https://api.openai.com), model
                               OPENAI_IMAGE_MODEL (default gpt-image-1); any OpenAI-compatible gateway works
The reference image is sent to the provider you choose. Only use art the user owns or is allowed to adapt.
"""
from __future__ import annotations

import argparse
import base64
import json
import mimetypes
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path

STYLE = {
    "pixel": ("Pixel-art sprite style: crisp chunky square pixels as if drawn at about 96 px tall and scaled up, "
              "limited palette, 1-pixel dark outline, no anti-aliasing, no gradients, no blur."),
    "smooth": ("Clean sticker illustration in exactly the same art style, line weight and colouring as the reference: "
               "bold clean outline, flat cel shading, bright saturated colours."),
}

RULES = (
    "Layout rules: an invisible {cols}-column × {rows}-row grid of equal cells; exactly one complete character per cell, "
    "centred, all at the same scale, feet/bottom roughly on the same line in each row. Leave wide empty gutters so "
    "nothing touches or crosses a neighbouring cell or the image edge. Background: fully transparent (or, if transparency "
    "is impossible, one flat plain colour that the character does not use). No grid lines, no borders, no frames, "
    "no speech bubbles, no text, no letters, no numbers, no watermark, no ground shadow. "
    "Faces must read clearly at 120 px: big expressive eyes and mouth, simple shapes."
)

def identity(m):
    c = m.get("character", {})
    parts = ["Use the attached reference image as the ONLY character model: same species/body shape, face, hair, "
             "colours, outfit and signature accessories in every cell."]
    if c.get("anchors"):
        parts.append(f"Identity anchors that must stay identical: {c['anchors']}.")
    if c.get("name"):
        parts.append(f"(The character is called {c['name']}; do not write the name anywhere.)")
    return " ".join(parts)

def group_prompt(m, g):
    style = STYLE.get(m.get("style", "smooth"), STYLE["smooth"])
    keys = int(g.get("keys", 1))
    entries = g["entries"]
    if keys > 1:
        cols, rows = keys, len(entries)
        lines = []
        for r, e in enumerate(entries, 1):
            kp = e.get("key_poses") or []
            if len(kp) != keys:
                raise SystemExit(f"「{e.get('text')}」需要 {keys} 个 key_poses")
            frames = "; ".join(f"frame {k + 1}: {p}" for k, p in enumerate(kp))
            lines.append(f"Row {r} — one short animation loop ({e.get('pose', e.get('text', ''))}), "
                         f"left to right: {frames}.")
        body = ("A sprite sheet of animation keyframes. Each ROW is one looping animation of the same character; "
                "inside a row only the body parts named below change, everything else (size, position of the feet, "
                "camera, lighting) stays fixed so the frames can be flipped through without jitter.\n" + "\n".join(lines))
    else:
        cols, rows = g.get("grid") or grid_for(len(entries))
        lines = [f"Cell {i + 1} (row {i // cols + 1}, column {i % cols + 1}): {e.get('pose') or e.get('text')}"
                 for i, e in enumerate(entries)]
        body = ("A character sticker sheet with a different pose and expression in each cell, in reading order "
                "(left→right, top→bottom). Every pose must be clearly different from the others — different arms, "
                "body angle, props or expression, not just a different mouth.\n" + "\n".join(lines))
        empties = cols * rows - len(entries)
        if empties:
            body += f"\nThe last {empties} cell(s) stay completely empty."
    return f"{identity(m)}\n{style}\n{body}\n" + RULES.format(cols=cols, rows=rows), (cols, rows)

def grid_for(n):
    if n <= 4:
        return [n, 1]
    if n <= 8:
        return [4, 2]
    if n <= 12:
        return [4, 3]
    if n <= 16:
        return [4, 4]
    raise SystemExit("一张图集最多 16 格；把这一组拆成两组")

RATIOS = {"1:1": 1, "4:3": 4 / 3, "3:2": 1.5, "2:1": 2, "3:1": 3, "3:4": 3 / 4, "2:3": 2 / 3, "1:2": 0.5}

def aspect(cols, rows):
    r = cols / rows
    return min(RATIOS, key=lambda k: abs(RATIOS[k] - r))

def ref_bytes(path: Path, max_side=768):
    """Reference shrunk to <=768px PNG: plenty for identity, much faster and steadier to upload."""
    from io import BytesIO
    from PIL import Image
    im = Image.open(path)
    im.thumbnail((max_side, max_side))
    buf = BytesIO()
    im.save(buf, "PNG", optimize=True)
    return buf.getvalue()

def data_uri(path: Path):
    return "data:image/png;base64," + base64.b64encode(ref_bytes(path)).decode()

# ------------------------------------------------------------------ providers

def _json(url, key, body=None, timeout=90, tries=6):
    """One HTTP call with its own retries, so a flaky connection never throws away a paid task."""
    data = json.dumps(body).encode() if body is not None else None
    for i in range(tries):
        req = urllib.request.Request(url, data=data,
                                     headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return json.load(r)
        except urllib.error.HTTPError:
            raise
        except (urllib.error.URLError, TimeoutError, ConnectionError, OSError):
            if i == tries - 1:
                raise
            time.sleep(3 + 3 * i)

def _download(url, dest: Path, tries=6):
    for i in range(tries):
        try:
            urllib.request.urlretrieve(url, dest)
            return
        except (urllib.error.URLError, TimeoutError, ConnectionError, OSError):
            if i == tries - 1:
                raise
            time.sleep(3 + 3 * i)

def hiapi(prompt, ref: Path | None, ratio, dest: Path, resolution="1K"):
    key = os.environ.get("HIAPI_API_KEY") or sys.exit("没有 HIAPI_API_KEY 环境变量")
    api = "https://api.hiapi.ai/v1/tasks"
    inp = {"prompt": prompt, "aspect_ratio": ratio, "resolution": resolution}
    if resolution == "1K":
        inp["background"] = "transparent"
    if ref:
        inp["input_urls"] = [data_uri(ref)]
    model = "gpt-image-2/image-to-image" if ref else "gpt-image-2/text-to-image"
    tid = _json(api, key, {"model": model, "input": inp})["data"]["taskId"]
    wait = 6
    for _ in range(90):
        time.sleep(wait)
        wait = min(wait * 1.3, 20)
        d = _json(f"{api}/{tid}", key)["data"]
        if d["status"] == "success":
            _download(d["output"][0]["url"], dest)
            return
        if d["status"] in ("fail", "failed", "error"):
            raise RuntimeError(f"hiapi 任务失败：{json.dumps(d, ensure_ascii=False)[:400]}")
    raise TimeoutError("hiapi 超时")

def openai(prompt, ref: Path | None, ratio, dest: Path, resolution="1K"):
    key = os.environ.get("OPENAI_API_KEY") or sys.exit("没有 OPENAI_API_KEY 环境变量")
    base = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com").rstrip("/")
    if not base.endswith("/v1"):
        base += "/v1"
    model = os.environ.get("OPENAI_IMAGE_MODEL", "gpt-image-1")
    size = {"1:1": "1024x1024"}.get(ratio, "1536x1024" if RATIOS[ratio] > 1 else "1024x1536")
    fields = {"model": model, "prompt": prompt, "size": size, "background": "transparent", "n": "1",
              "output_format": "png"}
    if ref:
        boundary = uuid.uuid4().hex
        body = b""
        for k, v in fields.items():
            body += f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
        body += (f'--{boundary}\r\nContent-Disposition: form-data; name="image[]"; filename="reference.png"\r\n'
                 f'Content-Type: image/png\r\n\r\n').encode()
        body += ref_bytes(ref, 1024) + f"\r\n--{boundary}--\r\n".encode()
        req = urllib.request.Request(f"{base}/images/edits", data=body, headers={
            "Authorization": f"Bearer {key}", "Content-Type": f"multipart/form-data; boundary={boundary}"})
        with urllib.request.urlopen(req, timeout=300) as r:
            res = json.load(r)
    else:
        res = _json(f"{base}/images/generations", key, fields, timeout=300)
    item = res["data"][0]
    if item.get("b64_json"):
        dest.write_bytes(base64.b64decode(item["b64_json"]))
    else:
        urllib.request.urlretrieve(item["url"], dest)

PROVIDERS = {"hiapi": hiapi, "openai": openai}

def generate(provider, prompt, ref, ratio, dest, resolution):
    last = None
    for attempt in range(3):
        try:
            PROVIDERS[provider](prompt, ref, ratio, dest, resolution)
            from PIL import Image
            Image.open(dest).load()
            return
        except (urllib.error.URLError, RuntimeError, TimeoutError, OSError, KeyError) as e:
            last = e
            print(f"  第 {attempt + 1} 次失败：{str(e)[:200]}，重试…", flush=True)
            time.sleep(5)
    raise SystemExit(f"生成失败：{last}")

# ------------------------------------------------------------------ main

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("manifest", type=Path)
    ap.add_argument("--provider", choices=list(PROVIDERS))
    ap.add_argument("--group", type=int, action="append", help="只生成第几组（可重复）；会覆盖已有图集")
    ap.add_argument("--print-prompts", action="store_true", help="只打印提示词，交给你自己的出图工具")
    ap.add_argument("--resolution", default="1K", choices=["1K", "2K"],
                    help="1K 支持原生透明背景；2K 更清晰但背景要靠 cut_atlas 去掉")
    ap.add_argument("--make-reference", metavar="描述", help="用户只有文字描述时，先画一张角色设定图")
    a = ap.parse_args()

    mpath = a.manifest
    m = json.loads(mpath.read_text(encoding="utf-8"))
    base = mpath.parent
    char = m.setdefault("character", {})

    if a.make_reference:
        if not a.provider:
            sys.exit("--make-reference 需要 --provider")
        dest = base / "ref" / "character.png"
        dest.parent.mkdir(parents=True, exist_ok=True)
        p = (f"Character design reference for a chat sticker mascot: {a.make_reference}. One single character, "
             f"full body, front three-quarter view, neutral happy expression, centred, plain transparent background, "
             f"no text. {STYLE.get(m.get('style', 'smooth'))}")
        print("画角色设定图…", flush=True)
        generate(a.provider, p, None, "1:1", dest, "1K")
        char["reference"] = str(dest.relative_to(base))
        mpath.write_text(json.dumps(m, ensure_ascii=False, indent=1), encoding="utf-8")
        print(f"设定图：{dest}（先给用户看，满意了再生成图集）")
        return

    ref = base / char["reference"] if char.get("reference") else None
    if ref and not ref.exists() and not a.print_prompts:
        sys.exit(f"找不到参考图 {ref}")
    todo = []
    for gi, g in enumerate(m["groups"], 1):
        if a.group and gi not in a.group:
            continue
        if not a.group and not a.print_prompts and g.get("atlas") and (base / g["atlas"]).exists():
            continue
        todo.append((gi, g))
    if not todo:
        print("所有组都已经有图集了；要重画某组用 --group N")
        return
    for gi, g in todo:
        prompt, (cols, rows) = group_prompt(m, g)
        ratio = aspect(cols, rows)
        if a.print_prompts:
            print(f"===== 组 {gi}「{g['name']}」 {cols}x{rows}  比例 {ratio}  → 保存为 atlases/g{gi:02d}.png\n{prompt}\n")
            continue
        if not a.provider:
            sys.exit("请指定 --provider，或用 --print-prompts 交给自己的出图工具")
        dest = base / "atlases" / f"g{gi:02d}.png"
        dest.parent.mkdir(parents=True, exist_ok=True)
        print(f"组 {gi}「{g['name']}」{cols}x{rows} 生成中…", flush=True)
        generate(a.provider, prompt, ref, ratio, dest, a.resolution)
        g["atlas"] = str(dest.relative_to(base))
        g["grid"] = [cols, rows]
        mpath.write_text(json.dumps(m, ensure_ascii=False, indent=1), encoding="utf-8")
        print(f"  → {dest}", flush=True)
    if not a.print_prompts:
        print("下一步：python3 cut_atlas.py", mpath)

if __name__ == "__main__":
    main()
```

## File: scripts/sticker_core.py
size: 21255
sha: 5bc07fb753a6c70a36ec413ee28c350285eb2f34
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/scripts/sticker_core.py

```text
"""Shared rendering core for the sticker pack scripts (Pillow only, no numpy)."""
from __future__ import annotations

import glob
import os
from dataclasses import dataclass
from math import cos, exp, pi, radians, sin

from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont

# ---------------------------------------------------------------- fonts

FONT_CANDIDATES = [
    # (path or glob, preferred face index for a bold weight)
    ("/System/Library/Fonts/Hiragino Sans GB.ttc", 2),
    ("/System/Library/AssetsV2/com_apple_MobileAsset_Font*/*/AssetData/PingFang.ttc", 0),
    ("/System/Library/Fonts/STHeiti Medium.ttc", 0),
    ("/usr/share/fonts/opentype/noto/NotoSansCJK-Black.ttc", 2),
    ("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", 2),
    ("/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc", 2),
    ("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", 0),
    ("C:/Windows/Fonts/msyhbd.ttc", 0),
    ("C:/Windows/Fonts/msyh.ttc", 0),
]

def _glyph(font, ch):
    im = Image.new("L", (64, 64))
    ImageDraw.Draw(im).text((8, 8), ch, font=font, fill=255)
    return im.tobytes()

def _missing_glyphs(font: ImageFont.FreeTypeFont, text: str) -> list[str]:
    tofu = {_glyph(font, "￿"), _glyph(font, "\U0010fffd")}
    out = []
    for ch in set(text):
        if ch.isspace():
            continue
        g = _glyph(font, ch)
        if g in tofu or not any(g):
            out.append(ch)
    return out

def find_font(texts: list[str], path: str | None = None, index: int | None = None):
    """Return (path, index) of a local font that renders every caption glyph."""
    joined = "".join(texts)
    tries = []
    if path:
        tries.append((path, index or 0))
    else:
        for pattern, idx in FONT_CANDIDATES:
            for p in sorted(glob.glob(pattern)):
                tries.append((p, idx))
    for p, idx in tries:
        try:
            f = ImageFont.truetype(p, 40, index=idx)
        except OSError:
            continue
        missing = _missing_glyphs(f, joined)
        if not missing:
            return p, idx
        if path:
            raise SystemExit(f"字体 {p} 缺字：{''.join(missing)}")
    raise SystemExit("没找到能显示全部文案的中文字体，请用 --font 指定一个 .ttf/.ttc/.otf")

# ---------------------------------------------------------------- motion
# Every motion is periodic in t∈[0,1) so the loop is seamless.
# Returns dx, dy (fraction of sprite height), sx, sy (scale), rot (degrees, + = clockwise),
# shear (fraction, top moves right by shear*height).

def _p(t, at, w):
    """Periodic gaussian bump centred at `at`."""
    d = min(abs(t - at), 1 - abs(t - at))
    return exp(-(d / w) ** 2)

def _hop(t, start, length):
    """Parabolic hop 0→1→0 between start and start+length (periodic)."""
    u = (t - start) % 1
    if u > length:
        return 0.0
    x = u / length
    return 4 * x * (1 - x)

@dataclass
class Pose:
    dx: float = 0.0
    dy: float = 0.0
    sx: float = 1.0
    sy: float = 1.0
    rot: float = 0.0
    shear: float = 0.0

def motion(kind: str, t: float, amp: float = 1.0) -> Pose:
    w = sin(2 * pi * t)
    k = MOTIONS.get(kind)
    if k is None:
        raise ValueError(f"未知动作 {kind!r}，可用：{', '.join(MOTIONS)}")
    p = k(t, w)
    if amp != 1.0:
        p = Pose(p.dx * amp, p.dy * amp, 1 + (p.sx - 1) * amp, 1 + (p.sy - 1) * amp, p.rot * amp, p.shear * amp)
    return p

def _land(t, at):
    s = _p(t, at, 0.05)
    return 1 + 0.10 * s, 1 - 0.10 * s

def _m_jump(t, w):
    h = _hop(t, 0.18, 0.42)
    sx, sy = _land(t, 0.62)
    pre = _p(t, 0.13, 0.05)
    return Pose(dy=-0.16 * h, sx=sx + 0.06 * pre - 0.04 * h, sy=sy - 0.06 * pre + 0.05 * h)

def _m_hop2(t, w):
    h = _hop(t, 0.05, 0.3) + _hop(t, 0.45, 0.3)
    s = _p(t, 0.36, 0.04) + _p(t, 0.76, 0.04)
    return Pose(dy=-0.09 * h, sx=1 + 0.08 * s, sy=1 - 0.08 * s, rot=4 * sin(4 * pi * t))

def _m_nod(t, w):
    a = _p(t, 0.3, 0.09) + 0.6 * _p(t, 0.55, 0.08)
    return Pose(dy=0.025 * a, sx=1 + 0.04 * a, sy=1 - 0.05 * a, shear=0.05 * a)

def _m_shake_head(t, w):
    s = sin(6 * pi * t) * (0.5 + 0.5 * cos(2 * pi * t))
    return Pose(dx=0.025 * s, rot=-3 * s)

def _m_sway(t, w):
    return Pose(rot=6 * w, dx=0.012 * w, sy=1 + 0.012 * cos(4 * pi * t))

def _m_breathe(t, w):
    return Pose(sx=1 - 0.015 * w, sy=1 + 0.025 * w)

def _m_float(t, w):
    return Pose(dy=-0.05 - 0.04 * w, rot=2.5 * cos(2 * pi * t))

def _m_tremble(t, w):
    s = sin(16 * pi * t)
    c = cos(12 * pi * t)
    return Pose(dx=0.012 * s, dy=0.006 * c, rot=1.2 * c, sx=1 - 0.01 * s, sy=1 + 0.01 * s)

def _m_startle(t, w):
    h = _hop(t, 0.12, 0.26)
    after = _p(t, 0.45, 0.12)
    return Pose(dy=-0.12 * h, sx=1 - 0.06 * h + 0.03 * after, sy=1 + 0.10 * h - 0.04 * after,
                dx=0.01 * sin(20 * pi * t) * after)

def _m_pop(t, w):
    a = _p(t, 0.22, 0.07)
    b = _p(t, 0.34, 0.06)
    return Pose(sx=1 + 0.16 * a - 0.04 * b, sy=1 + 0.16 * a - 0.04 * b, dy=-0.02 * a)

def _m_heartbeat(t, w):
    a = _p(t, 0.18, 0.05) + 0.8 * _p(t, 0.34, 0.05)
    return Pose(sx=1 + 0.07 * a, sy=1 + 0.07 * a)

def _m_droop(t, w):
    a = 0.5 - 0.5 * cos(2 * pi * t)
    return Pose(dy=0.04 * a, sx=1 + 0.05 * a, sy=1 - 0.07 * a, rot=-2 * a)

def _m_lean(t, w):
    a = _p(t, 0.4, 0.2)
    return Pose(shear=0.12 * a, dx=0.03 * a, rot=2 * a)

def _m_bow(t, w):
    a = _p(t, 0.4, 0.15)
    return Pose(shear=0.18 * a, sy=1 - 0.10 * a, sx=1 + 0.03 * a)

def _m_rush(t, w):
    return Pose(dx=0.04 * sin(4 * pi * t), dy=-0.04 * abs(sin(4 * pi * t)), rot=6, shear=0.04)

def _m_dance(t, w):
    return Pose(rot=6 * w, dy=-0.05 * abs(sin(2 * pi * t)), sx=1 + 0.03 * cos(4 * pi * t),
                sy=1 - 0.03 * cos(4 * pi * t), dx=0.02 * w)

def _m_stomp(t, w):
    a = _p(t, 0.3, 0.05) + _p(t, 0.55, 0.05)
    lift = _hop(t, 0.18, 0.12) + _hop(t, 0.43, 0.12)
    return Pose(dy=-0.04 * lift, sx=1 + 0.10 * a, sy=1 - 0.10 * a, dx=0.01 * sin(30 * pi * t) * a)

def _m_wiggle(t, w):
    return Pose(sx=1 + 0.04 * sin(4 * pi * t), sy=1 - 0.04 * sin(4 * pi * t), rot=3 * w)

def _m_dizzy(t, w):
    return Pose(rot=7 * sin(2 * pi * t), dx=0.03 * cos(2 * pi * t), dy=0.015 * sin(4 * pi * t))

def _m_peek(t, w):
    a = 0.5 - 0.5 * cos(2 * pi * t)
    return Pose(dx=0.06 * (a - 0.5), rot=-5 * (a - 0.5), dy=0.02 * a)

def _m_slide(t, w):
    # glides in from the left, holds, glides out to the right
    if t < 0.25:
        x = -0.5 * (1 - t / 0.25) ** 2
    elif t < 0.8:
        x = 0.0
    else:
        x = 0.5 * ((t - 0.8) / 0.2) ** 2
    return Pose(dx=x, rot=-6 * x)

def _m_none(t, w):
    return Pose()

MOTIONS = {
    "none": _m_none, "breathe": _m_breathe, "float": _m_float, "nod": _m_nod, "shake_head": _m_shake_head,
    "sway": _m_sway, "jump": _m_jump, "hop2": _m_hop2, "startle": _m_startle, "pop": _m_pop,
    "heartbeat": _m_heartbeat, "tremble": _m_tremble, "droop": _m_droop, "lean": _m_lean, "bow": _m_bow,
    "rush": _m_rush, "dance": _m_dance, "stomp": _m_stomp, "wiggle": _m_wiggle, "dizzy": _m_dizzy,
    "peek": _m_peek, "slide": _m_slide,
}
# motions that leave the frame on purpose and skip the clipping check
OFFSTAGE = {"slide"}

def transform_sprite(sprite: Image.Image, pose: Pose, resample) -> tuple[Image.Image, tuple[float, float]]:
    """Apply scale/shear/rotation anchored at bottom-centre. Returns image and the new anchor position."""
    w, h = sprite.size
    pad = int(max(w, h) * 0.6) + 4
    big = Image.new("RGBA", (w + 2 * pad, h + 2 * pad))
    big.alpha_composite(sprite, (pad, pad))
    ax, ay = pad + w / 2, pad + h  # anchor (bottom centre)
    # forward matrix: translate(-a) -> scale -> shear -> rotate -> translate(+a)
    r = radians(pose.rot)
    cr, sr = cos(r), sin(r)
    # M = R * Sh * S
    a, b = pose.sx, pose.shear * -1 * pose.sy  # shear: x' = x - shear*y  (y up is negative)
    c, d = 0.0, pose.sy
    m00 = cr * a - sr * c
    m01 = cr * b - sr * d
    m10 = sr * a + cr * c
    m11 = sr * b + cr * d
    det = m00 * m11 - m01 * m10
    i00, i01, i10, i11 = m11 / det, -m01 / det, -m10 / det, m00 / det
    # inverse mapping for PIL: src = Minv*(dst - a) + a
    data = (i00, i01, ax - i00 * ax - i01 * ay, i10, i11, ay - i10 * ax - i11 * ay)
    out = big.transform(big.size, Image.Transform.AFFINE, data, resample=resample)
    return out, (ax, ay)

# ---------------------------------------------------------------- icons (vector, drawn at any scale)

def _star(d, cx, cy, r, fill, ink, ow):
    pts = []
    for i in range(8):
        rr = r if i % 2 == 0 else r * 0.32
        a = pi / 4 * i - pi / 2
        pts.append((cx + rr * cos(a), cy + rr * sin(a)))
    d.polygon(pts, fill=fill, outline=ink, width=ow)

def _heart(d, cx, cy, r, fill, ink, ow):
    pts = []
    for i in range(48):
        a = 2 * pi * i / 48
        x = 16 * sin(a) ** 3
        y = -(13 * cos(a) - 5 * cos(2 * a) - 2 * cos(3 * a) - cos(4 * a))
        pts.append((cx + x * r / 17, cy + y * r / 17))
    d.polygon(pts, fill=fill, outline=ink, width=ow)

def _drop(d, cx, cy, r, fill, ink, ow):
    pts = [(cx, cy - r * 1.2)]
    for i in range(25):
        a = pi * i / 24
        pts.append((cx + r * 0.7 * cos(a), cy + r * 0.25 + r * 0.7 * sin(a)))
    d.polygon(pts, fill=fill, outline=ink, width=ow)

def _anger(d, cx, cy, r, fill, ink, ow):
    """💢-style vein mark: four bracket arcs bowing toward the centre."""
    w = max(2, int(r * 0.28))
    for sx, sy in ((-1, -1), (1, -1), (-1, 1), (1, 1)):
        ox, oy = cx + sx * r * 0.62, cy + sy * r * 0.62
        q = r * 0.5
        box = (ox - q, oy - q, ox + q, oy + q)
        start = {(-1, -1): 0, (1, -1): 90, (1, 1): 180, (-1, 1): 270}[(sx, sy)]
        d.arc(box, start, start + 90, fill=ink, width=w + 2 * ow)
    for sx, sy in ((-1, -1), (1, -1), (-1, 1), (1, 1)):
        ox, oy = cx + sx * r * 0.62, cy + sy * r * 0.62
        q = r * 0.5
        box = (ox - q, oy - q, ox + q, oy + q)
        start = {(-1, -1): 0, (1, -1): 90, (1, 1): 180, (-1, 1): 270}[(sx, sy)]
        d.arc(box, start + 6, start + 84, fill=fill, width=w)

def _note(d, cx, cy, r, fill, ink, ow):
    """Eighth note drawn as shapes (fonts often lack ♪)."""
    hx, hy, q = cx - r * 0.25, cy + r * 0.45, r * 0.38
    stem_x = hx + q * 0.85
    w = max(2, int(r * 0.18))
    d.line([(stem_x, hy), (stem_x, cy - r * 0.8)], fill=ink, width=w + 2 * ow)
    d.polygon([(stem_x, cy - r * 0.8), (stem_x + r * 0.6, cy - r * 0.45), (stem_x + r * 0.55, cy - r * 0.2),
               (stem_x, cy - r * 0.5)], fill=fill, outline=ink, width=ow)
    d.ellipse((hx - q, hy - q * 0.75, hx + q, hy + q * 0.75), fill=fill, outline=ink, width=ow)
    d.line([(stem_x, hy), (stem_x, cy - r * 0.8)], fill=fill, width=w)

def _text_icon(ch):
    def draw(d, cx, cy, r, fill, ink, ow, _font_cache={}):
        size = max(8, int(r * 2.2))
        key = size
        font = _font_cache.get(key)
        if font is None:
            p, i = ICON_FONT
            font = ImageFont.truetype(p, size, index=i)
            _font_cache[key] = font
        d.text((cx, cy), ch, font=font, fill=fill, anchor="mm", stroke_width=max(1, ow), stroke_fill=ink)
    return draw

def _check(d, cx, cy, r, fill, ink, ow):
    pts = [(cx - r * 0.8, cy), (cx - r * 0.2, cy + r * 0.6), (cx + r * 0.9, cy - r * 0.7)]
    w = max(2, int(r * 0.35))
    d.line(pts, fill=ink, width=w + 2 * ow, joint="curve")
    d.line(pts, fill=fill, width=w, joint="curve")

def _cross(d, cx, cy, r, fill, ink, ow):
    w = max(2, int(r * 0.35))
    for a, b in (((cx - r * .7, cy - r * .7), (cx + r * .7, cy + r * .7)), ((cx + r * .7, cy - r * .7), (cx - r * .7, cy + r * .7))):
        d.line([a, b], fill=ink, width=w + 2 * ow)
    for a, b in (((cx - r * .7, cy - r * .7), (cx + r * .7, cy + r * .7)), ((cx + r * .7, cy - r * .7), (cx - r * .7, cy + r * .7))):
        d.line([a, b], fill=fill, width=w)

def _puff(d, cx, cy, r, fill, ink, ow):
    for ox, oy, rr in ((-0.45, 0.1, 0.5), (0.1, -0.25, 0.6), (0.5, 0.15, 0.45)):
        x, y, q = cx + ox * r, cy + oy * r, rr * r
        d.ellipse((x - q, y - q, x + q, y + q), fill=fill, outline=ink, width=ow)

def _swirl(d, cx, cy, r, fill, ink, ow):
    pts = []
    for i in range(60):
        a = i / 60 * 4 * pi
        rr = r * (0.15 + 0.85 * i / 60)
        pts.append((cx + rr * cos(a), cy + rr * sin(a) * 0.8))
    w = max(2, int(r * 0.2))
    d.line(pts, fill=ink, width=w + 2 * ow, joint="curve")
    d.line(pts, fill=fill, width=w, joint="curve")

def _bolt(d, cx, cy, r, fill, ink, ow):
    pts = [(cx + .2 * r, cy - r), (cx - .5 * r, cy + .1 * r), (cx, cy + .1 * r), (cx - .2 * r, cy + r),
           (cx + .5 * r, cy - .15 * r), (cx, cy - .15 * r)]
    d.polygon(pts, fill=fill, outline=ink, width=ow)

def _dots(d, cx, cy, r, fill, ink, ow):
    for i in (-1, 0, 1):
        x = cx + i * r * 0.75
        q = r * 0.24
        d.ellipse((x - q, cy - q, x + q, cy + q), fill=fill, outline=ink, width=ow)

def _lines(d, cx, cy, r, fill, ink, ow):
    w = max(2, int(r * 0.22))
    for i, a in enumerate((-30, 0, 30)):
        aa = radians(a - 90)
        p0 = (cx + r * 0.3 * cos(aa), cy + r * 0.3 * sin(aa))
        p1 = (cx + r * cos(aa), cy + r * sin(aa))
        d.line([p0, p1], fill=ink, width=w + 2 * ow)
        d.line([p0, p1], fill=fill, width=w)

def _flower(d, cx, cy, r, fill, ink, ow):
    for i in range(5):
        a = 2 * pi * i / 5 - pi / 2
        x, y, q = cx + r * .5 * cos(a), cy + r * .5 * sin(a), r * .42
        d.ellipse((x - q, y - q, x + q, y + q), fill=fill, outline=ink, width=ow)
    q = r * .3
    d.ellipse((cx - q, cy - q, cx + q, cy + q), fill=ink)

ICON_FONT = ("", 0)  # set by caller via set_icon_font()

ICONS = {
    "spark": _star, "heart": _heart, "tear": _drop, "sweat": _drop, "anger": _anger,
    "question": _text_icon("?"), "exclaim": _text_icon("!"), "zzz": _text_icon("z"), "note": _note,
    "check": _check, "cross": _cross, "steam": _puff, "swirl": _swirl, "bolt": _bolt, "dots": _dots,
    "lines": _lines, "flower": _flower,
}

def set_icon_font(path, index):
    global ICON_FONT
    ICON_FONT = (path, index)

def icon_track(icon: str, t: float, seed: int, box):
    """Yield (name, cx, cy, radius_fraction, alpha_scale) placements for one frame.
    box = (left, top, right, bottom) of the sprite in canvas units; radius in canvas units."""
    if icon in (None, "", "none"):
        return []
    L, T, R, B = box
    w, h = R - L, B - T
    side = 1 if seed % 2 else -1
    hx = (R + 0.02 * w) if side > 0 else (L - 0.02 * w)  # beside the head
    hy = T + 0.12 * h
    base = 0.11 * h
    out = []
    wv = sin(2 * pi * t)
    if icon in ("heart", "note", "flower", "zzz"):
        for j in range(3):
            ph = (t + j / 3) % 1
            grow = min(1, ph * 4) * (1 - max(0, ph - 0.75) * 4)
            out.append((icon, hx + side * (ph * 0.18 * w - 0.05 * w), hy - ph * 0.35 * h + 0.1 * h,
                        base * (0.6 + 0.5 * grow) * (1.1 if j == 0 else 0.85), grow))
    elif icon in ("tear",):
        for j in range(4):
            ph = (2 * t + j * 0.27) % 1
            sgn = 1 if j % 2 else -1
            out.append((icon, (L + R) / 2 + sgn * (0.22 * w + 0.12 * w * ph), T + 0.3 * h + ph * 0.25 * h,
                        base * 0.5, 1 - ph ** 3))
    elif icon == "sweat":
        ph = t
        out.append(("sweat", hx - side * 0.04 * w, hy + 0.04 * h + ph * 0.1 * h, base * 0.6, 1))
    elif icon == "spark":
        for j, (ox, oy) in enumerate(((0, 0), (0.1, 0.22), (-0.06, 0.4))):
            ph = (t * 2 + j * 0.33) % 1
            s = sin(pi * ph)
            out.append(("spark", hx + side * ox * w, hy + oy * h - 0.06 * h, base * (0.3 + 0.8 * s), 1))
            side = -side if j == 0 else side
            hx = (R + 0.02 * w) if side > 0 else (L - 0.02 * w)
    elif icon == "anger":
        a = 0.5 + 0.5 * abs(sin(4 * pi * t))
        out.append(("anger", hx, hy - 0.03 * h, base * (0.95 + 0.3 * a), 1))
    elif icon == "steam":
        for j in range(2):
            ph = (t + j * 0.5) % 1
            out.append(("steam", (L + R) / 2 + (j - 0.5) * 0.4 * w, T - 0.02 * h - ph * 0.15 * h,
                        base * (0.5 + 0.5 * ph), 1 - ph ** 2))
    elif icon == "swirl":
        out.append(("swirl", (L + R) / 2 + 0.02 * w * wv, T - 0.04 * h, base * 0.9, 1))
    elif icon == "lines":
        out.append(("lines", hx, hy - 0.06 * h, base * (0.8 + 0.25 * _p(t, 0.3, 0.1)), 1))
    elif icon == "dots":
        n = int(t * 4) % 4
        out.append(("dots", hx + side * 0.05 * w, hy - 0.02 * h, base * 1.1, 1 if n else 0.35))
    else:  # check, cross, question, exclaim, bolt: single bouncy badge
        hit = _p(t, 0.3, 0.12)
        out.append((icon, hx, hy - 0.06 * h - 0.05 * h * hit + 0.01 * h * wv, base * (0.9 + 0.35 * hit), 1))
    return out

def draw_icons(layer: Image.Image, placements, fill, ink, outline_w):
    if not placements:
        return
    d = ImageDraw.Draw(layer)
    W, H = layer.size
    m = W * 0.035
    for name, cx, cy, r, a in placements:
        if a <= 0.15 or r < 1:
            continue
        # keep every accent fully inside the canvas (plus room for the die-cut border)
        cx = min(max(cx, r * 1.2 + m), W - r * 1.2 - m)
        cy = min(max(cy, r * 1.3 + m), H * 0.72)
        ICONS[name](d, cx, cy, r, fill, ink, outline_w)

# ---------------------------------------------------------------- outline / caption

def die_cut(layer: Image.Image, radius: float, color, smooth: bool) -> Image.Image:
    """Put a sticker-style border behind every opaque pixel."""
    if radius <= 0:
        return layer
    alpha = layer.getchannel("A")
    if smooth:
        r = max(1, int(round(radius)))
        grown = alpha.filter(ImageFilter.MaxFilter(2 * (r // 2) + 1)) if r >= 2 else alpha
        if r >= 3:
            grown = grown.filter(ImageFilter.GaussianBlur(r / 2.2)).point(lambda v: 255 if v > 24 else int(v * 255 / 24))
            grown = ImageChops.lighter(grown, alpha.filter(ImageFilter.MaxFilter(2 * (r // 2) + 1)))
    else:
        r = max(1, int(round(radius)))
        grown = alpha.point(lambda v: 255 if v >= 128 else 0).filter(ImageFilter.MaxFilter(2 * r + 1))
    base = Image.new("RGBA", layer.size, color)
    base.putalpha(grown)
    base.alpha_composite(layer)
    return base

def fit_font(font_path, font_index, text, max_w, start):
    size = start
    while size > 10:
        f = ImageFont.truetype(font_path, size, index=font_index)
        if f.getlength(text) <= max_w:
            return f
        size -= 1
    return ImageFont.truetype(font_path, size, index=font_index)

def caption_layer(text, S, ink, font_path, font_index, fx, t, stroke_color="#FFFFFF", pos="bottom"):
    """Render the caption for time t. fx: static | pop | type | wave | shake."""
    layer = Image.new("RGBA", (S, S))
    if not text:
        return layer
    text = text.translate(str.maketrans({"！": "!", "？": "?", "～": "~"}))
    n = len(text)
    start = int(S * (0.14 if n <= 4 else 0.12 if n <= 6 else 0.10))
    font = fit_font(font_path, font_index, text, S * 0.88, start)
    cy = S * (0.885 if pos == "bottom" else 0.11)
    stroke = max(2, int(font.size * 0.14))
    d = ImageDraw.Draw(layer)
    if fx in ("static", "pop"):
        if fx == "pop":
            k = min(1.0, t / 0.18)
            scale = 0.5 + 0.5 * k + 0.12 * sin(pi * min(1, t / 0.28)) if t < 0.28 else 1.0
            f2 = ImageFont.truetype(font_path, max(8, int(font.size * scale)), index=font_index)
            d.text((S / 2, cy), text, font=f2, fill=ink, anchor="mm", stroke_width=stroke, stroke_fill=stroke_color)
        else:
            d.text((S / 2, cy), text, font=font, fill=ink, anchor="mm", stroke_width=stroke, stroke_fill=stroke_color)
        return layer
    # per-character effects
    widths = [font.getlength(c) for c in text]
    total = sum(widths)
    x = S / 2 - total / 2
    visible = n
    if fx == "type":
        visible = max(1, min(n, int(t / 0.5 * n) + 1))
    for i, (c, cw) in enumerate(zip(text, widths)):
        if i >= visible:
            break
        oy = ox = 0.0
        if fx == "wave":
            oy = -font.size * 0.12 * sin(2 * pi * (t - i / max(n, 1) * 0.5))
        elif fx == "shake":
            hit = _p(t, 0.3, 0.1)
            ox = font.size * 0.05 * sin(37 * t + i * 2.1) * hit
            oy = font.size * 0.05 * cos(29 * t + i * 1.7) * hit
        d.text((x + cw / 2 + ox, cy + oy), c, font=font, fill=ink, anchor="mm", stroke_width=stroke,
               stroke_fill=stroke_color)
        x += cw
    return layer

# ---------------------------------------------------------------- helpers

def hex_rgb(h):
    h = h.lstrip("#")
    return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))

def dhash(img: Image.Image, size=12) -> int:
    g = img.convert("RGBA")
    bg = Image.new("RGBA", g.size, (255, 255, 255, 255))
    bg.alpha_composite(g)
    g = bg.convert("L").resize((size + 1, size), Image.Resampling.BILINEAR)
    px = list(g.getdata())
    bits = 0
    for y in range(size):
        for x in range(size):
            bits = (bits << 1) | (px[y * (size + 1) + x] > px[y * (size + 1) + x + 1])
    return bits

def hamming(a: int, b: int) -> int:
    return bin(a ^ b).count("1")
```

## File: references/manifest.md
size: 3981
sha: df65f53171fc73139876fa64f35247da4cbec103
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/references/manifest.md

````text
# manifest.json

One file drives the whole pipeline. `gen_atlas.py` fills `atlas`/`grid`, `cut_atlas.py` fills `sprites`, `build_pack.py` reads everything. Paths are relative to the manifest.

```json
{
  "title": "饭团猫日常",
  "style": "smooth",
  "count": 24,
  "character": {
    "name": "饭团猫",
    "reference": "ref/character.png",
    "anchors": "chubby white onigiri-shaped cat, green nori band on belly, red umeboshi on head, big round black eyes"
  },
  "palette": {"ink": "#2F3A4A", "accent": "#FF6F91", "outline": "#FFFFFF"},
  "caption": {"fx": "pop", "pos": "bottom"},
  "platforms": ["generic", "wechat"],
  "groups": [
    {
      "name": "日常回复",
      "atlas": "atlases/g01.png", "grid": [4, 4],
      "entries": [
        {"text": "收到", "pose": "saluting with the right paw, confident smile", "motion": "nod", "icon": "check",
         "sprites": ["sprites/g01_01.png"]}
      ]
    },
    {
      "name": "逐帧动作",
      "keys": 4,
      "atlas": "atlases/g02.png", "grid": [4, 4],
      "entries": [
        {"text": "拜拜", "pose": "waving goodbye",
         "key_poses": ["paw down", "paw raised, palm open", "paw tilted left", "paw tilted right"],
         "timing": [2, 1, 1, 1], "motion": "breathe", "icon": "none",
         "sprites": ["sprites/g02_01.png", "sprites/g02_02.png", "sprites/g02_03.png", "sprites/g02_04.png"]}
      ]
    }
  ]
}
```

## Top level

| field | default | meaning |
|---|---|---|
| `title` | 表情包 | pack name; used for the ZIP and gallery |
| `style` | `smooth` | `smooth` = anti-aliased illustration (soft alpha kept in APNG/WebP/WebM). `pixel` = nearest-neighbour, hard edges, pixel-art outline |
| `count` | – | optional; build fails if the real count differs |
| `palette.ink` | `#3B2A5A` | caption fill and icon outline (a group may override `ink`/`accent`) |
| `palette.accent` | `#FF7AA8` | icon fill |
| `palette.outline` | `#FFFFFF` | die-cut border around the art and caption stroke |
| `caption.fx` | `pop` | default caption animation |
| `caption.pos` | `bottom` | `bottom` or `top` |
| `platforms` | `["generic"]` | see platforms.md; `--platform` overrides |

## Group

| field | meaning |
|---|---|
| `name` | category shown in gallery |
| `keys` | 1 (default) = one pose per cell. N>1 = each atlas ROW is one sticker's N keyframes, left→right |
| `atlas` | sheet path (string, or a list when one group spans several sheets) |
| `grid` | `[cols, rows]`; for `keys` groups this is `[keys, entries]` |

## Entry

| field | meaning |
|---|---|
| `text` | caption; empty string = no caption (art gets bigger) |
| `pose` | English drawing instruction for the cell (or the loop's theme for keyframes) |
| `key_poses` | keys groups only: exactly `keys` short descriptions, one per frame |
| `sprites` | filled by `cut_atlas.py`; 1 path = static pose, 2+ = keyframes |
| `timing` | relative hold length per keyframe, e.g. `[2,1,1,1]` |
| `motion` | procedural motion layered on top (keyframe stickers often use `none` or `breathe`) |
| `amp` | motion strength multiplier, default 1.0 |
| `icon` | moving accent, or `none` |
| `caption_fx` | overrides the pack default |

## Motions

`none` `breathe` `float` `nod` `shake_head` `sway` `jump` `hop2` `startle` `pop` `heartbeat` `tremble` `droop` `lean` `bow` `rush` `dance` `stomp` `wiggle` `dizzy` `peek` `slide`

All are seamless loops. `slide` leaves the frame on purpose (exempt from the clipping check).

## Icons

`spark` `heart` `tear` `sweat` `anger` `question` `exclaim` `zzz` `note` `check` `cross` `steam` `swirl` `bolt` `dots` `lines` `flower` `none`

Vector-drawn, so they stay sharp at any output size; in `pixel` style they are drawn on the low-res grid and come out pixelated to match. Use `none` when the generated art already has its own effect marks.

## Caption effects

`static` · `pop` (springs in) · `type` (characters appear one by one) · `wave` (characters bob in sequence) · `shake` (jolts on the beat)
````

## File: references/phrases.md
size: 4391
sha: 41600e46f05b089b24709c01e272833fb8504651
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/references/phrases.md

```text
# 文案库 · 高频聊天用语 → 动作搭配

Use these when the user did not supply lines. Pick across categories so a 16-pack covers reply / emotion / social / daily life, not 16 kinds of "happy". Captions: ≤4 characters reads best at 120 px thumbnails; 5–6 is the ceiling.

`motion` + `icon` columns are starting points; change them if the drawn pose suggests something better. ★ = gesture-driven, best as a `keys: 4` keyframe sticker.

## 回复 · 确认

| 文案 | pose 提示 | motion | icon | caption_fx |
|---|---|---|---|---|
| 收到 | salute | nod | check | pop |
| 好的 | OK hand sign, wink | pop | spark | pop |
| 了解 | nodding, paw raised like taking notes | nod | check | static |
| 没问题 | thumping chest confidently | stomp | bolt | pop |
| 马上来 | running pose, hand raised | rush | lines | shake |
| 稍等 | palm out "wait" gesture, apologetic smile | sway | dots | type |
| 在吗 | peeking, head tilted | peek | question | type |
| 我在 | hand up eagerly | jump | exclaim | pop |
| ★点赞 | thumbs up rising | pop | spark | pop |
| ★鼓掌 | clapping | none | spark | wave |

## 开心 · 夸夸

| 文案 | pose | motion | icon | fx |
|---|---|---|---|---|
| 好耶 | jumping, both arms up | hop2 | spark | pop |
| 哈哈哈哈 | doubled over laughing | tremble | none | shake |
| 太棒了 | arms spread, sparkling eyes | jump | spark | pop |
| 爱你 | hugging a big heart | heartbeat | heart | pop |
| ★比心 | finger heart forming | heartbeat | heart | pop |
| 好可爱 | hands on cheeks, blushing | wiggle | heart | wave |
| 绝了 | double thumbs up, stars in eyes | pop | spark | shake |
| 谢谢老板 | deep bow, paws together | bow | spark | pop |

## 难过 · 委屈 · 生气

| 文案 | pose | motion | icon | fx |
|---|---|---|---|---|
| 委屈 | poking fingers, watery eyes | droop | tear | type |
| ★哭哭 | crying streams | tremble | none | type |
| 心碎 | holding a cracked heart | droop | none | static |
| 生气了 | puffed cheeks, hands on hips | stomp | anger | shake |
| 哼 | turning away, arms crossed, nose up | shake_head | steam | pop |
| 无语 | flat stare, sweat drop | none | dots | type |
| 裂开 | cracked face, shocked | startle | bolt | shake |
| 救命 | flailing arms, panic | tremble | exclaim | shake |

## 疑问 · 惊讶

| 文案 | pose | motion | icon | fx |
|---|---|---|---|---|
| 我想想 | paw on chin, eyes up | sway | dots | type |
| 啊？ | head tilt, one ear down | peek | question | pop |
| 震惊 | eyes wide, paws on cheeks | startle | exclaim | shake |
| 真的假的 | squinting suspiciously | lean | question | type |
| 晕了 | spiral eyes, wobbling | dizzy | swirl | wave |

## 社交 · 问候

| 文案 | pose | motion | icon | fx |
|---|---|---|---|---|
| 早安 | stretching or holding a drink | sway | flower | pop |
| 晚安 | in a nightcap, hugging pillow | float | zzz | wave |
| ★拜拜 | waving | breathe | none | pop |
| 抱抱 | arms open | wiggle | heart | wave |
| 辛苦了 | offering a cup of tea | bow | steam | static |
| 对不起 | bowing with sweat drop | bow | sweat | type |
| 欢迎 | arms open, confetti | hop2 | spark | pop |
| 加油 | headband, fists up | jump | lines | pop |

## 日常 · 打工

| 文案 | pose | motion | icon | fx |
|---|---|---|---|---|
| 摸鱼中 | lying down hugging a fish | float | note | wave |
| ★打字中 | typing on laptop | none | dots | type |
| ★干饭 | eating with chopsticks | none | heart | pop |
| ★困了 | yawning | none | zzz | wave |
| 下班了 | running with a bag | rush | spark | pop |
| 溜了 | running away looking back | rush | sweat | shake |
| 冲鸭 | dash forward, fist out | lean | lines | shake |
| 在忙 | juggling papers, sweat | tremble | sweat | type |
| 开会中 | holding a "mute" sign... (no text on art) — use a closed-mouth zipper gesture | none | dots | static |

## Picking a pack

- 8 张: 收到 · 好耶 · 哈哈哈哈 · 委屈 · 生气了 · 抱抱 · 晚安 · ★拜拜
- 16 张: the 8 above + 在吗 · 我想想 · 震惊 · 谢谢老板 · 加油 · 摸鱼中 · ★点赞 · ★干饭
- 24 张: the 16 above + 好的 · 溜了 · 早安 · 冲鸭 · ★鼓掌 · ★打字中 · ★困了 · ★比心

Never put lettering inside the generated art — captions are added by the renderer so they stay editable, sharp and spell-checked.
```

## File: references/platforms.md
size: 2599
sha: 11e39e8ce0dc2f42a44f5f25d30132f94a22be2c
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/references/platforms.md

```text
# Platform presets

Public requirements as of 2026-11. The platform's own console is the final word; re-check before a submission.
`build_pack.py` encodes each sticker per platform and steps down colours / quality / frame count until the file fits the budget. `report.json` → `by_platform.<name>.setting` says what it used.

| key | what you get | canvas | format | per-file limit | other rules checked |
|---|---|---|---|---|---|
| `generic` | GIF to send in WeChat/QQ/飞书/钉钉 chats directly | 320×320 | GIF, 24 f × 80 ms, infinite loop | 1 MB (soft) | transparency, clipping, motion |
| `wechat` | 微信表情开放平台 主图 | 240×240 | GIF | 500 KB | album = any 8–24; extras: 120×120 缩略图 per sticker, 240×240 封面 PNG and 50×50 聊天页图标 PNG (both: transparent, no white outline, no text — taken from the reference art) |
| `telegram` | Telegram video sticker | 512×512 | WebM VP9 + alpha, no audio | 256 KB | ≤3 s, ≤30 fps; extra: 100×100 pack thumbnail PNG |
| `line` | LINE animation sticker | 320×270 | APNG, 12 f × 160 ms, 2 loops | 300 KB | 5–20 frames, total play ≤4 s, pack = 8/16/24; extras: main 240×240, tab 96×74 |
| `discord` | Discord server sticker | 320×320 | APNG | 512 KB | |
| `slack` | Slack custom emoji | 128×128 | GIF, 12 f × 160 ms | 128 KB | |
| `whatsapp` | WhatsApp animated sticker | 512×512 | animated WebP | 500 KB | extra: 96×96 tray icon |

Still manual after the build:

- **WeChat 表情开放平台** (form checked 2026-11-05): 表情名称 ≤8 汉字 no punctuation; 表情介绍 ≤80; 版权信息 ≤10 (designer/studio name if unregistered); 详情页横幅 750×400 JPG/PNG ≤500KB, no text, not white, not transparent; 赞赏引导语 5–15 字 + 引导图 750×560 + 致谢图 750×750 (≤500KB each); one 含义词 per sticker; 原创类型 / 角色 / 风格 (1–2) / 主题. 赞赏 needs complete 艺术家信息 first. The price field only appears for accounts with paid stickers enabled.
- **LINE Creators Market**: account + listing text; LINE re-checks every APNG.
- **Telegram**: upload via @Stickers bot (`/newvideo`), one file at a time, then the pack icon.
- **Discord**: server must have free sticker slots (boost level dependent).

Notes

- GIF has 1-bit transparency. The white die-cut border hides the stair-step edge, so stickers look clean on both light and dark chat backgrounds; check the dark rows in `review/motion-*.png`.
- APNG, WebP and WebM keep full soft alpha.
- Telegram needs ffmpeg built with `libvpx-vp9`. Check with `ffmpeg -hide_banner -encoders | grep vp9`.
```

## File: .gitignore
size: 65
sha: e3f83cf74414dd9d5bd79ad3315d040968dc812c
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/.gitignore

```text
out/
__pycache__/
*.index.png
.DS_Store
.env*
wechat-submission/
```

## File: agents/openai.yaml
size: 335
sha: 691e555f2b9882e7d49469bf00ecb1e40ca80b76
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/agents/openai.yaml

```text
interface:
  display_name: "动态表情包工坊"
  short_description: "Character → real keyframe-animated sticker pack, exported per platform spec and verified"
  default_prompt: "使用 $animated-sticker-studio，用这张图里的角色做 16 张会动的中文表情包，导出微信表情开放平台规格，打包给我。"
```

## File: examples/onigiri-cat/atlases/g01.png
size: 1842523
sha: 5906af21ab449342f34cbc7f0bd3d55111efee27
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/atlases/g01.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/atlases/g01.png

## File: examples/onigiri-cat/atlases/g02.png
size: 1749950
sha: f7a67993cbbbccb36f4159ea536accabd0e92345
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/atlases/g02.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/atlases/g02.png

## File: examples/onigiri-cat/atlases/g03.png
size: 1777407
sha: fe414b366db7c7e4c948efc51998b8198369643b
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/atlases/g03.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/atlases/g03.png

## File: examples/onigiri-cat/contact.png
size: 587191
sha: 09e017cdbc836bc6058d953b6b2d4d97fa2510f1
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/contact.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/contact.png

## File: examples/onigiri-cat/gif/001-收到.gif
size: 381883
sha: c54d5e55fbccf849e70512b9fa079dc9dbaf800c
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/001-%E6%94%B6%E5%88%B0.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/001-%E6%94%B6%E5%88%B0.gif

## File: examples/onigiri-cat/gif/002-好耶.gif
size: 372881
sha: 545457929651134c0e79d150b19f4c51f4895b3b
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/002-%E5%A5%BD%E8%80%B6.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/002-%E5%A5%BD%E8%80%B6.gif

## File: examples/onigiri-cat/gif/003-抱抱.gif
size: 411084
sha: 1cfc699ab96e093915834be4cd2ad2645c1a19d8
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/003-%E6%8A%B1%E6%8A%B1.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/003-%E6%8A%B1%E6%8A%B1.gif

## File: examples/onigiri-cat/gif/004-早安.gif
size: 402719
sha: f1924974a184474708c7e8db1d05dc810b390632
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/004-%E6%97%A9%E5%AE%89.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/004-%E6%97%A9%E5%AE%89.gif

## File: examples/onigiri-cat/gif/005-在吗.gif
size: 350287
sha: e3c4e45e371c05aa6aa364a5e12cfdf26298c67a
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/005-%E5%9C%A8%E5%90%97.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/005-%E5%9C%A8%E5%90%97.gif

## File: examples/onigiri-cat/gif/006-哈哈哈哈.gif
size: 382298
sha: 00046b9c1879bff227f2745c019e5edbe69ff3d5
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/006-%E5%93%88%E5%93%88%E5%93%88%E5%93%88.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/006-%E5%93%88%E5%93%88%E5%93%88%E5%93%88.gif

## File: examples/onigiri-cat/gif/007-委屈.gif
size: 381095
sha: 9e209f44bc9f9a9a7073dde0f4bba3fd6e23a92e
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/007-%E5%A7%94%E5%B1%88.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/007-%E5%A7%94%E5%B1%88.gif

## File: examples/onigiri-cat/gif/008-生气了.gif
size: 342140
sha: d81795bd83ce80f6d440aa3ad0ab284b4d6026d8
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/008-%E7%94%9F%E6%B0%94%E4%BA%86.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/008-%E7%94%9F%E6%B0%94%E4%BA%86.gif

## File: examples/onigiri-cat/gif/009-我想想.gif
size: 376658
sha: e466290a2837b2975e194d9c76dfeea7a6edf773
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/009-%E6%88%91%E6%83%B3%E6%83%B3.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/009-%E6%88%91%E6%83%B3%E6%83%B3.gif

## File: examples/onigiri-cat/gif/010-加油.gif
size: 340687
sha: 69822223d1553f31257031dafdb3b282502aa5dc
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/010-%E5%8A%A0%E6%B2%B9.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/010-%E5%8A%A0%E6%B2%B9.gif

## File: examples/onigiri-cat/gif/011-谢谢老板.gif
size: 401833
sha: 7dcc14a797f448cc73e7f58a5e92bf37548ac14a
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/011-%E8%B0%A2%E8%B0%A2%E8%80%81%E6%9D%BF.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/011-%E8%B0%A2%E8%B0%A2%E8%80%81%E6%9D%BF.gif

## File: examples/onigiri-cat/gif/012-溜了.gif
size: 348899
sha: c9a8c824a41430483aa62eb460ae0d1028fece74
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/012-%E6%BA%9C%E4%BA%86.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/012-%E6%BA%9C%E4%BA%86.gif

## File: examples/onigiri-cat/gif/013-震惊.gif
size: 356465
sha: de6a39ee841fd901d0c045275859ff2346abac83
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/013-%E9%9C%87%E6%83%8A.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/013-%E9%9C%87%E6%83%8A.gif

## File: examples/onigiri-cat/gif/014-摸鱼中.gif
size: 442558
sha: b140e3f555e4c7a087c95cb4ac087a1eecbc968f
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/014-%E6%91%B8%E9%B1%BC%E4%B8%AD.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/014-%E6%91%B8%E9%B1%BC%E4%B8%AD.gif

## File: examples/onigiri-cat/gif/015-好的.gif
size: 383580
sha: 44f72ec7bba37f802825657ab9327a662889cf6f
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/015-%E5%A5%BD%E7%9A%84.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/015-%E5%A5%BD%E7%9A%84.gif

## File: examples/onigiri-cat/gif/016-冲鸭.gif
size: 367151
sha: 2d2a85c27e428bef7c16d1ef9a4510f006516cf4
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/016-%E5%86%B2%E9%B8%AD.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/016-%E5%86%B2%E9%B8%AD.gif

## File: examples/onigiri-cat/gif/017-拜拜.gif
size: 361234
sha: 82efd35f8270d75c28de42642bdfdfaf1d98f49f
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/017-%E6%8B%9C%E6%8B%9C.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/017-%E6%8B%9C%E6%8B%9C.gif

## File: examples/onigiri-cat/gif/018-鼓掌.gif
size: 398106
sha: fca248c92a736cf96806f2c1011af2b584778e76
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/018-%E9%BC%93%E6%8E%8C.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/018-%E9%BC%93%E6%8E%8C.gif

## File: examples/onigiri-cat/gif/019-打字中.gif
size: 95074
sha: 221374653f754ecd347579ba490548a1137f3138
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/019-%E6%89%93%E5%AD%97%E4%B8%AD.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/019-%E6%89%93%E5%AD%97%E4%B8%AD.gif

## File: examples/onigiri-cat/gif/020-干饭.gif
size: 405067
sha: 4eeb3dc652fe473ba51a145f238fd54c88c14de6
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/020-%E5%B9%B2%E9%A5%AD.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/020-%E5%B9%B2%E9%A5%AD.gif

## File: examples/onigiri-cat/gif/021-点赞.gif
size: 397740
sha: 9531ba67336a276932b6d33a008159de9f6795fa
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/021-%E7%82%B9%E8%B5%9E.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/021-%E7%82%B9%E8%B5%9E.gif

## File: examples/onigiri-cat/gif/022-哭哭.gif
size: 379852
sha: 52b459c65186ebeb901d74b0cf4dabc00e3a6a55
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/022-%E5%93%AD%E5%93%AD.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/022-%E5%93%AD%E5%93%AD.gif

## File: examples/onigiri-cat/gif/023-困了.gif
size: 364322
sha: 048e9c04b2066fa1c17319345a393952b988ac69
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/023-%E5%9B%B0%E4%BA%86.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/023-%E5%9B%B0%E4%BA%86.gif

## File: examples/onigiri-cat/gif/024-比心.gif
size: 390494
sha: 869629afe110c4102f8ce66f61d7ff22240a54dd
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/024-%E6%AF%94%E5%BF%83.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/gif/024-%E6%AF%94%E5%BF%83.gif

## File: examples/onigiri-cat/hero.webp
size: 1224724
sha: da0d2a49cb1474fdfea7fb6de68024b93e4c3417
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/hero.webp

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/hero.webp

## File: examples/onigiri-cat/manifest.json
size: 9346
sha: 6736fd5ebb4f27a9e626673c16025b137f085e52
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/manifest.json

```text
{
 "title": "饭团猫日常",
 "style": "smooth",
 "count": 24,
 "character": {
  "name": "饭团猫",
  "reference": "ref/character.png",
  "anchors": "a chubby white cat whose body is shaped like a rounded-triangle onigiri rice ball; a dark green nori seaweed band wraps around its belly; a small red pickled-plum (umeboshi) sits on top of its head between two small pointed ears; big round black eyes with white highlights; tiny pink nose; short stubby paws; no tail visible"
 },
 "palette": {
  "ink": "#2F3A4A",
  "accent": "#FF6F91",
  "outline": "#FFFFFF"
 },
 "caption": {
  "fx": "pop",
  "pos": "bottom"
 },
 "platforms": [
  "generic",
  "wechat",
  "telegram",
  "line"
 ],
 "groups": [
  {
   "name": "日常回复",
   "entries": [
    {
     "text": "收到",
     "pose": "standing straight, saluting with the right paw at the forehead, confident closed-mouth smile",
     "motion": "nod",
     "icon": "check",
     "sprites": [
      "sprites/g01_01.png"
     ]
    },
    {
     "text": "好耶",
     "pose": "jumping in the air with both paws raised high, eyes squeezed shut, huge open-mouth grin",
     "motion": "hop2",
     "icon": "spark",
     "sprites": [
      "sprites/g01_02.png"
     ]
    },
    {
     "text": "抱抱",
     "pose": "both arms stretched wide open toward the viewer asking for a hug, pink blushing cheeks",
     "motion": "wiggle",
     "icon": "heart",
     "sprites": [
      "sprites/g01_03.png"
     ]
    },
    {
     "text": "早安",
     "pose": "holding a tiny steaming cup of milk in one paw, other paw waving, fresh morning smile",
     "motion": "sway",
     "icon": "flower",
     "sprites": [
      "sprites/g01_04.png"
     ]
    },
    {
     "text": "在吗",
     "pose": "leaning sideways with the head tilted, one paw raised next to the face, curious round eyes",
     "motion": "peek",
     "icon": "question",
     "sprites": [
      "sprites/g01_05.png"
     ]
    },
    {
     "text": "哈哈哈哈",
     "pose": "doubled over laughing, holding its belly, tears of joy at the eye corners, mouth wide open",
     "motion": "tremble",
     "icon": "none",
     "caption_fx": "shake",
     "sprites": [
      "sprites/g01_06.png"
     ]
    },
    {
     "text": "委屈",
     "pose": "sitting, huge watery eyes, small pout, poking two index fingers together shyly",
     "motion": "droop",
     "icon": "tear",
     "caption_fx": "type",
     "sprites": [
      "sprites/g01_07.png"
     ]
    },
    {
     "text": "生气了",
     "pose": "puffed-up cheeks, angry eyebrows, both paws on hips, ears pinned back",
     "motion": "stomp",
     "icon": "none",
     "caption_fx": "shake",
     "sprites": [
      "sprites/g01_08.png"
     ]
    },
    {
     "text": "我想想",
     "pose": "one paw on the chin, eyes looking up and to the side, pondering",
     "motion": "sway",
     "icon": "dots",
     "sprites": [
      "sprites/g01_09.png"
     ]
    },
    {
     "text": "加油",
     "pose": "wearing a red headband, both fists raised in a cheering pose, determined sparkling eyes",
     "motion": "jump",
     "icon": "lines",
     "sprites": [
      "sprites/g01_10.png"
     ]
    },
    {
     "text": "谢谢老板",
     "pose": "bowing deeply at the waist with both paws pressed together in thanks",
     "motion": "bow",
     "icon": "spark",
     "sprites": [
      "sprites/g01_11.png"
     ]
    },
    {
     "text": "溜了",
     "pose": "running away to the right in a mid-stride pose, looking back over the shoulder, a sweat drop",
     "motion": "rush",
     "icon": "sweat",
     "sprites": [
      "sprites/g01_12.png"
     ]
    },
    {
     "text": "震惊",
     "pose": "eyes wide as plates, mouth open in an O, both paws on cheeks, fur standing on end",
     "motion": "startle",
     "icon": "none",
     "sprites": [
      "sprites/g01_13.png"
     ]
    },
    {
     "text": "摸鱼中",
     "pose": "lying belly-down and relaxed, hugging a small blue fish plushie, half-closed content eyes",
     "motion": "float",
     "icon": "note",
     "caption_fx": "wave",
     "sprites": [
      "sprites/g01_14.png"
     ]
    },
    {
     "text": "好的",
     "pose": "winking and making an OK hand sign with one paw next to the face",
     "motion": "pop",
     "icon": "spark",
     "sprites": [
      "sprites/g01_15.png"
     ]
    },
    {
     "text": "冲鸭",
     "pose": "leaning far forward in a dash, determined face, one fist thrust forward",
     "motion": "lean",
     "icon": "lines",
     "sprites": [
      "sprites/g01_16.png"
     ]
    }
   ],
   "atlas": "atlases/g01.png",
   "grid": [
    4,
    4
   ]
  },
  {
   "name": "逐帧动作 A",
   "keys": 4,
   "entries": [
    {
     "text": "拜拜",
     "pose": "waving goodbye",
     "key_poses": [
      "right paw down at the side, smiling",
      "right paw raised high, palm open",
      "raised paw tilted to the left",
      "raised paw tilted to the right"
     ],
     "motion": "breathe",
     "icon": "none",
     "timing": [
      2,
      1,
      1,
      1
     ],
     "sprites": [
      "sprites/g02_01.png",
      "sprites/g02_02.png",
      "sprites/g02_03.png",
      "sprites/g02_04.png"
     ]
    },
    {
     "text": "鼓掌",
     "pose": "clapping happily",
     "key_poses": [
      "paws spread apart at chest height, mouth open",
      "paws touching together in a clap, eyes closed happily",
      "paws spread apart again",
      "paws together in a clap"
     ],
     "motion": "none",
     "icon": "spark",
     "timing": [
      1,
      1,
      1,
      1
     ],
     "sprites": [
      "sprites/g02_05.png",
      "sprites/g02_06.png",
      "sprites/g02_07.png",
      "sprites/g02_08.png"
     ]
    },
    {
     "text": "打字中",
     "pose": "typing on a tiny laptop in front of it",
     "key_poses": [
      "left paw pressing a key, right paw lifted",
      "right paw pressing a key, left paw lifted",
      "left paw pressing a key, right paw lifted, eyes focused",
      "right paw pressing a key, left paw lifted"
     ],
     "motion": "none",
     "icon": "dots",
     "timing": [
      1,
      1,
      1,
      1
     ],
     "caption_fx": "type",
     "sprites": [
      "sprites/g02_09.png",
      "sprites/g02_10.png",
      "sprites/g02_11.png",
      "sprites/g02_12.png"
     ]
    },
    {
     "text": "干饭",
     "pose": "eating from a small rice bowl with chopsticks",
     "key_poses": [
      "holding bowl, chopsticks lifting rice toward the mouth",
      "rice at the open mouth",
      "cheeks puffed, chewing with eyes closed happily",
      "cheeks puffed chewing, tiny smile"
     ],
     "motion": "none",
     "icon": "heart",
     "timing": [
      1,
      1,
      1,
      1
     ],
     "sprites": [
      "sprites/g02_13.png",
      "sprites/g02_14.png",
      "sprites/g02_15.png",
      "sprites/g02_16.png"
     ]
    }
   ],
   "atlas": "atlases/g02.png",
   "grid": [
    4,
    4
   ]
  },
  {
   "name": "逐帧动作 B",
   "keys": 4,
   "entries": [
    {
     "text": "点赞",
     "pose": "giving a thumbs-up",
     "key_poses": [
      "fist at chest, neutral smile",
      "thumb pops up, eyes open wide",
      "thumbs-up held high, winking",
      "thumbs-up held high, winking, big grin"
     ],
     "motion": "pop",
     "icon": "spark",
     "timing": [
      1,
      1,
      2,
      2
     ],
     "sprites": [
      "sprites/g03_01.png",
      "sprites/g03_02.png",
      "sprites/g03_03.png",
      "sprites/g03_04.png"
     ]
    },
    {
     "text": "哭哭",
     "pose": "crying",
     "key_poses": [
      "lip trembling, eyes filling with tears",
      "eyes shut, two streams of tears, mouth wide open wailing",
      "eyes shut, streams of tears, mouth half closed sobbing",
      "lip trembling, eyes full of tears"
     ],
     "motion": "tremble",
     "icon": "none",
     "timing": [
      1,
      2,
      2,
      1
     ],
     "caption_fx": "type",
     "sprites": [
      "sprites/g03_05.png",
      "sprites/g03_06.png",
      "sprites/g03_07.png",
      "sprites/g03_08.png"
     ]
    },
    {
     "text": "困了",
     "pose": "getting sleepy",
     "key_poses": [
      "droopy half-closed eyes, sitting",
      "big yawn with mouth wide open, one paw covering the mouth",
      "eyes closed, head nodding down",
      "eyes closed, head nodded down asleep with a snot bubble"
     ],
     "motion": "none",
     "icon": "zzz",
     "timing": [
      1,
      2,
      1,
      2
     ],
     "caption_fx": "wave",
     "sprites": [
      "sprites/g03_09.png",
      "sprites/g03_10.png",
      "sprites/g03_11.png",
      "sprites/g03_12.png"
     ]
    },
    {
     "text": "比心",
     "pose": "making a finger heart",
     "key_poses": [
      "paw down at the side, shy smile",
      "paw rising to the chest",
      "thumb and index finger crossed into a tiny finger heart in front of the face, winking",
      "finger heart held out toward the viewer, blushing smile"
     ],
     "motion": "heartbeat",
     "icon": "heart",
     "timing": [
      1,
      1,
      2,
      2
     ],
     "sprites": [
      "sprites/g03_13.png",
      "sprites/g03_14.png",
      "sprites/g03_15.png",
      "sprites/g03_16.png"
     ]
    }
   ],
   "atlas": "atlases/g03.png",
   "grid": [
    4,
    4
   ]
  }
 ]
}
```

## File: examples/onigiri-cat/ref/character.png
size: 1060524
sha: 1716dd782f53de93fdc5ffd334fcb1e9e6df74d8
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/ref/character.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/ref/character.png

## File: examples/onigiri-cat/social-preview.png
size: 229989
sha: ff957bf908f221bbd391ce30a2bea3688c8ec3eb
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/social-preview.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/social-preview.png

## File: examples/onigiri-cat/sprites/g01_01.png
size: 105255
sha: 43a4f1998fce56ef73010a12781579e2411511f0
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_01.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_01.png

## File: examples/onigiri-cat/sprites/g01_02.png
size: 103827
sha: 84c0b460f413293b59804bb2afa7fa6177d15786
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_02.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_02.png

## File: examples/onigiri-cat/sprites/g01_03.png
size: 112194
sha: eca0a0715a0147436677172c27ebdbdbdce598d1
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_03.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_03.png

## File: examples/onigiri-cat/sprites/g01_04.png
size: 109966
sha: d318e71d4f2332ec136d664792e9afaca84e6b0b
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_04.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_04.png

## File: examples/onigiri-cat/sprites/g01_05.png
size: 105923
sha: 88e32f97a72a926bb52980c7c9f744378c72dec8
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_05.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_05.png

## File: examples/onigiri-cat/sprites/g01_06.png
size: 123935
sha: 95901ac30b449a518e96f8ba8f549fd42a7495e1
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_06.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_06.png

## File: examples/onigiri-cat/sprites/g01_07.png
size: 117404
sha: bcabf6a699a4e44d1c5bd751163be5da07ec142a
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_07.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_07.png

## File: examples/onigiri-cat/sprites/g01_08.png
size: 116258
sha: 77e725e44110bc0e6207348da98b67e6e941d626
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_08.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_08.png

## File: examples/onigiri-cat/sprites/g01_09.png
size: 109232
sha: e0e3af9f12b8d59e61f74bd166b726d6c819a760
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_09.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_09.png

## File: examples/onigiri-cat/sprites/g01_10.png
size: 126315
sha: 1a968baab15bd368f636ce09c8cc45fa94f5e265
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_10.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_10.png

## File: examples/onigiri-cat/sprites/g01_11.png
size: 97159
sha: 0d16d5b0e7d623ab1f3bb6d1c68c9f40f924e294
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_11.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_11.png

## File: examples/onigiri-cat/sprites/g01_12.png
size: 102189
sha: dd51f32fe0ae8e2781868bb32cddc90d56c59c5c
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_12.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_12.png

## File: examples/onigiri-cat/sprites/g01_13.png
size: 126019
sha: 7041b5b2c9277d36005948778d670bc5b3f4d054
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_13.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_13.png

## File: examples/onigiri-cat/sprites/g01_14.png
size: 99802
sha: 8403fd07483edaaa6d3d4a0a8aeac4fc00e45608
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_14.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_14.png

## File: examples/onigiri-cat/sprites/g01_15.png
size: 105205
sha: a7f1dc0a8295da61a07441cd700d6a782f289f79
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_15.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_15.png

## File: examples/onigiri-cat/sprites/g01_16.png
size: 98850
sha: be8d8c78b6b59038b74f3b84827f0ccc1568f5c7
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_16.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g01_16.png

## File: examples/onigiri-cat/sprites/g02_01.png
size: 100977
sha: 4254423a3296ace1fb17fd097b9ac95f62542116
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_01.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_01.png

## File: examples/onigiri-cat/sprites/g02_02.png
size: 103396
sha: 8cc7722725b09cd1a3caef9238f323cb3e4e367f
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_02.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_02.png

## File: examples/onigiri-cat/sprites/g02_03.png
size: 101855
sha: 801c88cefc22b32268f7474e57a257515b29eb0c
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_03.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_03.png

## File: examples/onigiri-cat/sprites/g02_04.png
size: 100842
sha: a0b2968798f8c5f0ccb7c4cf0ddd0750553289ee
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_04.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_04.png

## File: examples/onigiri-cat/sprites/g02_05.png
size: 107768
sha: be0e0c04b73d24b1af14bc5fe35b35dce402b3f7
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_05.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_05.png

## File: examples/onigiri-cat/sprites/g02_06.png
size: 107767
sha: a8416d4f63087b49725170a5d5044c007e07c8e4
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_06.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_06.png

## File: examples/onigiri-cat/sprites/g02_07.png
size: 107223
sha: c11bbc09d1e18220b56ae726b547a26d5c7f0876
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_07.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_07.png

## File: examples/onigiri-cat/sprites/g02_08.png
size: 105089
sha: a6e8a8a16ac3e8a4300cc3f857f46981acf88f56
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_08.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_08.png

## File: examples/onigiri-cat/sprites/g02_09.png
size: 102046
sha: 3db96df4e02741b20cd8eb774e05b1157104ef91
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_09.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_09.png

## File: examples/onigiri-cat/sprites/g02_10.png
size: 103161
sha: 26a482cbe9ff6e77f7f5089ab7130b77fc5ca59d
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_10.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_10.png

## File: examples/onigiri-cat/sprites/g02_11.png
size: 101350
sha: 12afbacaee0d7f21039fb4a0b78f5af18d8b019d
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_11.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_11.png

## File: examples/onigiri-cat/sprites/g02_12.png
size: 102063
sha: a7201ee113906a8747ebfc7747a79e1bb8154e37
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_12.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_12.png

## File: examples/onigiri-cat/sprites/g02_13.png
size: 111982
sha: b91e1164639bf3971f807e32211d61c3d9fd13ab
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_13.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_13.png

## File: examples/onigiri-cat/sprites/g02_14.png
size: 112428
sha: b9134aa9c3dbaf5b52dbf6a4fb89d0b81732c2c2
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_14.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_14.png

## File: examples/onigiri-cat/sprites/g02_15.png
size: 107552
sha: 3eebea6c3c93823eb1ab0c53f17ca7c6a69d0d58
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_15.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_15.png

## File: examples/onigiri-cat/sprites/g02_16.png
size: 106450
sha: 7ec052bd331676a48ee0816cd36d09d115584474
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_16.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g02_16.png

## File: examples/onigiri-cat/sprites/g03_01.png
size: 104457
sha: 6d9836ffcf4ebdf27c1fc250ac9ae311d99afbfe
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_01.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_01.png

## File: examples/onigiri-cat/sprites/g03_02.png
size: 104509
sha: b247952e86e4ea393b5e248fff506225470b4182
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_02.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_02.png

## File: examples/onigiri-cat/sprites/g03_03.png
size: 105173
sha: 6bd1837574c1280c6b02d15f860e58335d8ccdd2
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_03.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_03.png

## File: examples/onigiri-cat/sprites/g03_04.png
size: 109366
sha: 40fd44d18bfb1f5f6cd8c93ad4ac678b607a8a0e
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_04.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_04.png

## File: examples/onigiri-cat/sprites/g03_05.png
size: 105116
sha: 9e8b80fa4fe93e4640c50b9654ca76613d1192ae
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_05.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_05.png

## File: examples/onigiri-cat/sprites/g03_06.png
size: 115098
sha: e5a870e6c8a733c4261a0bc7b19f9b96ddee3da9
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_06.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_06.png

## File: examples/onigiri-cat/sprites/g03_07.png
size: 112285
sha: 93996ee46cf6e5963de5e58bc5938f05f6fa4d19
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_07.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_07.png

## File: examples/onigiri-cat/sprites/g03_08.png
size: 102772
sha: 29b2f192c251b6fc943761428607ef1b7c1011a1
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_08.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_08.png

## File: examples/onigiri-cat/sprites/g03_09.png
size: 107555
sha: 1a7ebc6d33f735f851d082e1d29347361080eced
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_09.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_09.png

## File: examples/onigiri-cat/sprites/g03_10.png
size: 106428
sha: ac491b14fd7227005559d59b91f8655784264119
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_10.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_10.png

## File: examples/onigiri-cat/sprites/g03_11.png
size: 99626
sha: f16a9c251513291937bc2976450dfa31583b4bb4
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_11.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_11.png

## File: examples/onigiri-cat/sprites/g03_12.png
size: 97562
sha: 520b03a831baa5214b42c102a55b23b2f9391ff9
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_12.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_12.png

## File: examples/onigiri-cat/sprites/g03_13.png
size: 103849
sha: 197eb686df939577ba936be4df6bbc043fb00b5d
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_13.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_13.png

## File: examples/onigiri-cat/sprites/g03_14.png
size: 103379
sha: aaa0fb3b55bc3d8484538b93c4a2f56d44ec1061
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_14.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_14.png

## File: examples/onigiri-cat/sprites/g03_15.png
size: 106553
sha: 719b2a312b28e02685e64b0c05591e7d7237b543
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_15.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_15.png

## File: examples/onigiri-cat/sprites/g03_16.png
size: 102147
sha: c44a93dff9e5898006b637727332abfddaaf49f1
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_16.png

Content is not inlined in the catalog. Read the upstream file: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/examples/onigiri-cat/sprites/g03_16.png

## File: LICENSE
size: 1068
sha: 8d1fe551fd7265d8475a234a9a1eb28770e60a57
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/LICENSE

```text
MIT License

Copyright (c) 2026 fantuan-lab

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

## File: README.zh-CN.md
size: 11178
sha: 950eca15e45f534b337e144708432942284b2628
upstream: https://github.com/fantuan-lab/animated-sticker-studio/blob/HEAD/README.zh-CN.md

````text
<p align="center">
  <img src="examples/onigiri-cat/hero.webp" width="100%" alt="饭团猫动态表情">
</p>

<p align="center">
  <b>一张角色图 → 一整套会动的聊天表情包。<br>直接导出微信表情开放平台、Telegram、LINE、Discord、Slack、WhatsApp 的上传规格。</b>
</p>

<p align="center">
  <a href="https://github.com/fantuan-lab/animated-sticker-studio/stargazers"><img src="https://img.shields.io/github/stars/fantuan-lab/animated-sticker-studio?style=flat&color=f5a623" alt="stars"></a>
  <a href="https://github.com/fantuan-lab/animated-sticker-studio/releases"><img src="https://img.shields.io/github/v/release/fantuan-lab/animated-sticker-studio?color=e85d75" alt="release"></a>
  <img src="https://img.shields.io/badge/Claude_Code-skill-d97757" alt="Claude Code skill">
  <img src="https://img.shields.io/badge/Codex-skill-10a37f" alt="Codex skill">
  <img src="https://img.shields.io/badge/WeChat%20%C2%B7%20Telegram%20%C2%B7%20LINE%20%C2%B7%20Discord%20%C2%B7%20Slack%20%C2%B7%20WhatsApp-6%20platforms-4c9be8" alt="WeChat, Telegram, LINE, Discord, Slack, WhatsApp">
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
</p>

<p align="center">
  <a href="README.md">English</a> | <a href="README.zh-CN.md"><b>中文</b></a>
</p>

# 动态表情包工坊 · Animated Sticker Studio

给 AI 一张角色图（或者一句描述），它帮你做一整套**真的会动**的聊天表情包：设计台词和动作、画出每一帧、加文字和小特效，按微信 / Telegram / LINE 等平台的上架规格导出，自动压到体积上限以内，自己检查一遍，再打包给你。

Claude Code、Codex 和其他能跑脚本的 AI 编程助手都能用。

## 示例：饭团猫 · 24 张

原创角色。这一套已经用本 skill 导出的微信版本，提交到微信表情开放平台审核（2026-11-05）。

一次生成：1 张设定图 + 3 张动作图集，共调用 4 次出图接口，没有手工修图。下面是微信表情开放平台规格（240×240，每张 ≤500KB）的成品。

**第 1–16 张**：单张姿势，加程序动效。**第 17–24 张**：逐帧动画，手真的在挥，眼泪真的在流，哈欠真的打出来。

| | | | | | |
|---|---|---|---|---|---|
| <img src="examples/onigiri-cat/gif/001-%E6%94%B6%E5%88%B0.gif" width="120" alt="001-收到"><br>001-收到 | <img src="examples/onigiri-cat/gif/002-%E5%A5%BD%E8%80%B6.gif" width="120" alt="002-好耶"><br>002-好耶 | <img src="examples/onigiri-cat/gif/003-%E6%8A%B1%E6%8A%B1.gif" width="120" alt="003-抱抱"><br>003-抱抱 | <img src="examples/onigiri-cat/gif/004-%E6%97%A9%E5%AE%89.gif" width="120" alt="004-早安"><br>004-早安 | <img src="examples/onigiri-cat/gif/005-%E5%9C%A8%E5%90%97.gif" width="120" alt="005-在吗"><br>005-在吗 | <img src="examples/onigiri-cat/gif/006-%E5%93%88%E5%93%88%E5%93%88%E5%93%88.gif" width="120" alt="006-哈哈哈哈"><br>006-哈哈哈哈 |
| <img src="examples/onigiri-cat/gif/007-%E5%A7%94%E5%B1%88.gif" width="120" alt="007-委屈"><br>007-委屈 | <img src="examples/onigiri-cat/gif/008-%E7%94%9F%E6%B0%94%E4%BA%86.gif" width="120" alt="008-生气了"><br>008-生气了 | <img src="examples/onigiri-cat/gif/009-%E6%88%91%E6%83%B3%E6%83%B3.gif" width="120" alt="009-我想想"><br>009-我想想 | <img src="examples/onigiri-cat/gif/010-%E5%8A%A0%E6%B2%B9.gif" width="120" alt="010-加油"><br>010-加油 | <img src="examples/onigiri-cat/gif/011-%E8%B0%A2%E8%B0%A2%E8%80%81%E6%9D%BF.gif" width="120" alt="011-谢谢老板"><br>011-谢谢老板 | <img src="examples/onigiri-cat/gif/012-%E6%BA%9C%E4%BA%86.gif" width="120" alt="012-溜了"><br>012-溜了 |
| <img src="examples/onigiri-cat/gif/013-%E9%9C%87%E6%83%8A.gif" width="120" alt="013-震惊"><br>013-震惊 | <img src="examples/onigiri-cat/gif/014-%E6%91%B8%E9%B1%BC%E4%B8%AD.gif" width="120" alt="014-摸鱼中"><br>014-摸鱼中 | <img src="examples/onigiri-cat/gif/015-%E5%A5%BD%E7%9A%84.gif" width="120" alt="015-好的"><br>015-好的 | <img src="examples/onigiri-cat/gif/016-%E5%86%B2%E9%B8%AD.gif" width="120" alt="016-冲鸭"><br>016-冲鸭 | <img src="examples/onigiri-cat/gif/017-%E6%8B%9C%E6%8B%9C.gif" width="120" alt="017-拜拜"><br>017-拜拜 | <img src="examples/onigiri-cat/gif/018-%E9%BC%93%E6%8E%8C.gif" width="120" alt="018-鼓掌"><br>018-鼓掌 |
| <img src="examples/onigiri-cat/gif/019-%E6%89%93%E5%AD%97%E4%B8%AD.gif" width="120" alt="019-打字中"><br>019-打字中 | <img src="examples/onigiri-cat/gif/020-%E5%B9%B2%E9%A5%AD.gif" width="120" alt="020-干饭"><br>020-干饭 | <img src="examples/onigiri-cat/gif/021-%E7%82%B9%E8%B5%9E.gif" width="120" alt="021-点赞"><br>021-点赞 | <img src="examples/onigiri-cat/gif/022-%E5%93%AD%E5%93%AD.gif" width="120" alt="022-哭哭"><br>022-哭哭 | <img src="examples/onigiri-cat/gif/023-%E5%9B%B0%E4%BA%86.gif" width="120" alt="023-困了"><br>023-困了 | <img src="examples/onigiri-cat/gif/024-%E6%AF%94%E5%BF%83.gif" width="120" alt="024-比心"><br>024-比心 |

<details>
<summary>同一套图导出的四个平台版本，全部通过规格检查</summary>

| 平台 | 规格 | 本套最大 / 平均体积 | 上限 |
|---|---|---|---|
| 通用 GIF | 320×320 GIF | 702KB / 578KB | 1MB（建议值） |
| 微信表情开放平台 | 240×240 GIF + 120 缩略图 + 透明封面和图标（无描边、无文字） | 442KB / 368KB | 500KB |
| Telegram 视频贴纸 | 512×512 WebM VP9 透明 | 128KB / 94KB | 256KB |
| LINE 动态贴图 | 320×270 APNG，12 帧，播 2 遍 | 298KB / 270KB | 300KB |

</details>

## 和 pixel-gif-sticker-pack 比，好在哪

[WoYun520/pixel-gif-sticker-pack](https://github.com/WoYun520/pixel-gif-sticker-pack) 是个不错的项目，示例很多，这个 skill 就是在读完它的代码之后做的。主要多做了这些：

| | pixel-gif-sticker-pack | 动态表情包工坊 |
|---|---|---|
| 角色本身会不会动 | 不会。每张只有一张静态图，靠平移、摇晃、挤压动起来（它的文档原话：*They do not generate new character poses inside a loop*） | 会。可以给一张表情画 2–4 个关键帧（挥手、鼓掌、打字、吃饭、哭、打哈欠……），再叠加程序动效 |
| 程序动效 | 16 种 | 22 种，都是无缝循环；跳跃、惊吓这类动作出框时会自动缩小这一张 |
| 文字动效 | 静态 | 5 种：弹出、逐字打出、波浪、抖动、静态 |
| 小特效 | 16 个像素图标 | 17 个矢量图标，任何尺寸都清晰；像素风下自动变成像素样式 |
| 画风 | 只有像素风 | 平滑插画风 + 像素风 |
| 导出平台 | 320×320 GIF 一种 | 通用 GIF、微信表情开放平台、Telegram、LINE、Discord、Slack、WhatsApp，各自的尺寸、格式、帧数、时长和体积上限 |
| 体积控制 | 不控制（示例每张约 400KB） | 每个平台自动降颜色、质量或帧数，直到低于上限，并记录用了哪档参数 |
| 平台附带图 | 无 | 微信缩略图、封面、图标，LINE main/tab，Telegram 和 WhatsApp 的图标 |
| 出图 | 只能用 Codex 自带的出图工具 | 支持自带工具（打印提示词），也内置 hiapi 和任意 OpenAI 兼容接口 |
| 抠图 | 图集必须已经是透明背景；格子不齐要手填像素坐标 | 自动去掉纯色背景和“假透明”棋盘格；按像素块识别每个角色，格子不齐也能切；每张图集出一张带编号的对照图 |
| 字体 | 必须手动传字体路径 | 自动找系统中文字体，并检查每个字都能显示 |
| 重复检查 | 只查完全相同的图 | 用感知哈希找“几乎一样”的姿势，并分组提示 |
| AI 怎么检查动画 | 导出 MP4（AI 看不了视频） | 生成动作检查图：每张拆 4 帧并排，白底和深色底交替，AI 能直接看出动作、出框和白边 |
| 给人看的预览 | 分类网页 | 离线网页：白底、深色模式、透明格、像素放大四种背景，还有“放进聊天里”的样子 |
| 适用助手 | Codex | Claude Code、Codex，以及其他能跑 Python 的助手 |

## 怎么用

**安装**

Claude Code：把这个文件夹放到 `~/.claude/skills/animated-sticker-studio/`，或者直接对 Claude 说：

> 帮我安装这个表情包 skill：https://github.com/fantuan-lab/animated-sticker-studio

Codex：放到 `~/.codex/skills/animated-sticker-studio/`（`agents/openai.yaml` 已经写好）。

需要 Python 3 和 Pillow（`pip install Pillow`）。导出 Telegram 版本或 MP4 预览还需要 ffmpeg。

**做一套**

把角色图发给 AI，再说一句：

> 用 animated-sticker-studio，拿这张图里的角色做 16 张会动的中文表情包，要上架微信表情开放平台。

也可以说“做 24 张打工人专用的”“用我自己的台词”“像素风”“再出一份 Telegram 版”。没有图也行，描述一下角色，它会先画一张设定图给你确认。

**出图接口：下面三种只要有一种就行，不用全部配置**

| 你在用 | 要准备什么 | 说明 |
|---|---|---|
| Codex | 什么都不用配 | Codex 自带出图工具，skill 会把提示词交给它 |
| Claude Code（推荐用 hiapi） | 一个 hiapi 的 key，设置成环境变量 `HIAPI_API_KEY` | 饭团猫就是用它做的，已实测。gpt-image-2，1K 透明背景，一张图集约 $0.04。没有账号可以用我的邀请链接注册：[hiapi.ai/invite/fantuan](https://hiapi.ai/invite/fantuan) |
| Claude Code（备选：OpenAI） | 已经有 OpenAI 或兼容网关的 key 才用这个：`OPENAI_API_KEY` | 代码已写好，但还没实测过；用 hiapi 的话不需要它 |

<details>
<summary>想自己跑脚本？</summary>

```sh
# 1. 写 manifest.json（格式见 references/manifest.md，台词参考 references/phrases.md）
# 2. 出图集
python3 scripts/gen_atlas.py manifest.json --provider hiapi
# 3. 切图，然后看 atlases/*.index.png，确认编号和台词对得上
python3 scripts/cut_atlas.py manifest.json
# 4. 渲染、导出、检查、打包
python3 scripts/build_pack.py manifest.json out --platform wechat,telegram --mp4
```

不想花钱出图，可以直接重建本仓库的示例（切好的图已经放在仓库里）：

```sh
cd examples/onigiri-cat
python3 ../../scripts/build_pack.py manifest.json out --platform generic,wechat
```

</details>

## 文件结构

```
SKILL.md                给 AI 看的工作流程
scripts/gen_atlas.py    写提示词、调用出图接口
scripts/cut_atlas.py    去背景、切图、生成编号对照图
scripts/build_pack.py   渲染、按平台导出、压缩、检查、预览、打包
scripts/sticker_core.py 动效、图标、文字、描边
references/             清单格式、平台规格、台词库
examples/onigiri-cat/   饭团猫示例：设定图、图集、切好的图、成品 GIF
```

## 注意

- 平台规格按 2026 年 11 月的公开要求整理，提交前以平台后台为准。微信上架还要准备横幅图和版权材料，这部分只能你自己来。
- 参考图会发给你选的出图服务。请只用你自己的角色，或者已经拿到授权的角色。

## 开源许可

[MIT](LICENSE)
````
