⏳ This skill is pending AI review.

Scores will appear once the review pipeline completes.

version unknown

animated-sticker-studio

@fantuan-lab⭐ 44 stars

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.

Choose how to use this skill

You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.

1. Native installer

This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.

Do not guess an installer command or replace an existing version without reviewing the diff.

2. Complete package recommended

Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.

No complete ProSkills package is published for this listing yet.

3. Prompt-only

Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.

Need only the instruction file?

Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.

No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.

—/10

// RATINGS

⭐GitHub Stars
⭐⭐ 44 on GitHubGitHub ↗

Growing

🟢ProSkills Score
—
📍

Not yet listed on ClawHub or SkillsMP

// README

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.

001-收到002-好耶003-抱抱004-早安005-在吗006-哈哈哈哈
007-委屈008-生气了009-我想想010-加油011-谢谢老板012-溜了
013-震惊014-摸鱼中015-好的016-冲鸭017-拜拜018-鼓掌
019-打字中020-干饭021-点赞022-哭哭023-困了024-比心
PlatformSpecLargest / average fileLimit
Generic GIF320×320 GIF702 KB / 578 KB1 MB (soft)
WeChat Sticker Platform240×240 GIF + 120 thumbnails + transparent cover and icon442 KB / 368 KB500 KB
Telegram video sticker512×512 transparent WebM VP9128 KB / 94 KB256 KB
LINE animated sticker320×270 APNG, 12 frames, 2 loops298 KB / 270 KB300 KB

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

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 useWhat to set upNotes
CodexNothingCodex has a built-in image tool; the skill hands it the prompts
Claude Code (recommended: hiapi)One hiapi key as HIAPI_API_KEYUsed 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
Claude Code (fallback: OpenAI)Onl

// HOW IT'S BUILT

// FILES

93 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.

  • SKILL.md5.4 KB8c98d5b325c6upstream ↗
    ---
    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.
    
  • README.md9.9 KB02944bf804d6upstream ↗
    <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)
    
  • scripts/build_pack.py36 KBb57388e1015bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • scripts/cut_atlas.py12 KB5d6d115e56dbupstream ↗
    #!/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()
    
  • scripts/gen_atlas.py13 KBb7d77554550fupstream ↗
    #!/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()
    
  • scripts/sticker_core.py21 KB5bc07fb753a6upstream ↗
    """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")
    
  • references/manifest.md3.9 KBdf65f53171fcupstream ↗
    # 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)
    
  • references/phrases.md4.3 KB41600e46f05bupstream ↗
    # 文案库 · 高频聊天用语 → 动作搭配
    
    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.
    
  • references/platforms.md2.5 KB11e39e8ce0dcupstream ↗
    # 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`.
    
  • .gitignore65 Be3f83cf74414upstream ↗
    out/
    __pycache__/
    *.index.png
    .DS_Store
    .env*
    wechat-submission/
    
  • agents/openai.yaml335 B691e555f2b98upstream ↗
    interface:
      display_name: "动态表情包工坊"
      short_description: "Character → real keyframe-animated sticker pack, exported per platform spec and verified"
      default_prompt: "使用 $animated-sticker-studio,用这张图里的角色做 16 张会动的中文表情包,导出微信表情开放平台规格,打包给我。"
    
  • examples/onigiri-cat/atlases/g01.png1.8 MB5906af21ab44upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/atlases/g02.png1.7 MBf7a67993cbbbupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/atlases/g03.png1.7 MBfe414b366db7upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/contact.png573 KB09e017cdbc83upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/001-收到.gif373 KBc54d5e55fbccupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/002-好耶.gif364 KB545457929651upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/003-抱抱.gif401 KB1cfc699ab96eupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/004-早安.gif393 KBf1924974a184upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/005-在吗.gif342 KBe3c4e45e371cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/006-哈哈哈哈.gif373 KB00046b9c1879upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/007-委屈.gif372 KB9e209f44bc9fupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/008-生气了.gif334 KBd81795bd83ceupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/009-我想想.gif368 KBe466290a2837upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/010-加油.gif333 KB69822223d155upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/011-谢谢老板.gif392 KB7dcc14a797f4upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/012-溜了.gif341 KBc9a8c824a414upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/013-震惊.gif348 KBde6a39ee841fupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/014-摸鱼中.gif432 KBb140e3f555e4upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/015-好的.gif375 KB44f72ec7bba3upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/016-冲鸭.gif359 KB2d2a85c27e42upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/017-拜拜.gif353 KB82efd35f8270upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/018-鼓掌.gif389 KBfca248c92a73upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/019-打字中.gif93 KB221374653f75upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/020-干饭.gif396 KB4eeb3dc652feupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/021-点赞.gif388 KB9531ba67336aupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/022-哭哭.gif371 KB52b459c65186upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/023-困了.gif356 KB048e9c04b206upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/gif/024-比心.gif381 KB869629afe110upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/hero.webp1.2 MBda0d2a49cb14upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/manifest.json9.1 KB6736fd5ebb4fupstream ↗
    {
     "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
       ]
      }
     ]
    }
  • examples/onigiri-cat/ref/character.png1.0 MB1716dd782f53upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/social-preview.png225 KBff957bf908f2upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_01.png103 KB43a4f1998fceupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_02.png101 KB84c0b460f413upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_03.png110 KBeca0a0715a01upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_04.png107 KBd318e71d4f23upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_05.png103 KB88e32f97a72aupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_06.png121 KB95901ac30b44upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_07.png115 KBbcabf6a699a4upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_08.png114 KB77e725e44110upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_09.png107 KBe0e3af9f12b8upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_10.png123 KB1a968baab15bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_11.png95 KB0d16d5b0e7d6upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_12.png100 KBdd51f32fe0aeupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_13.png123 KB7041b5b2c927upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_14.png97 KB8403fd07483eupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_15.png103 KBa7f1dc0a8295upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g01_16.png97 KBbe8d8c78b6b5upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_01.png99 KB4254423a3296upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_02.png101 KB8cc7722725b0upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_03.png99 KB801c88cefc22upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_04.png98 KBa0b2968798f8upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_05.png105 KBbe0e0c04b73dupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_06.png105 KBa8416d4f6308upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_07.png105 KBc11bbc09d1e1upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_08.png103 KBa6e8a8a16ac3upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_09.png100 KB3db96df4e027upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_10.png101 KB26a482cbe9ffupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_11.png99 KB12afbacaee0dupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_12.png100 KBa7201ee11390upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_13.png109 KBb91e1164639bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_14.png110 KBb9134aa9c3dbupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_15.png105 KB3eebea6c3c93upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g02_16.png104 KB7ec052bd3316upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_01.png102 KB6d9836ffcf4eupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_02.png102 KBb247952e86e4upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_03.png103 KB6bd1837574c1upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_04.png107 KB40fd44d18bfbupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_05.png103 KB9e8b80fa4fe9upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_06.png112 KBe5a870e6c8a7upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_07.png110 KB93996ee46cf6upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_08.png100 KB29b2f192c251upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_09.png105 KB1a7ebc6d33f7upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_10.png104 KBac491b14fd72upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_11.png97 KBf16a9c251513upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_12.png95 KB520b03a831baupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_13.png101 KB197eb686df93upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_14.png101 KBaaa0fb3b55bcupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_15.png104 KB719b2a312b28upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/onigiri-cat/sprites/g03_16.png100 KBc44a93dff9e5upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • LICENSE1.0 KB8d1fe551fd72upstream ↗
    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.
    
  • README.zh-CN.md11 KB950eca15e45fupstream ↗
    <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)
    

Plain-text copy, no login: /skill-bundles/fantuan-lab-animated-sticker-studio.md

// REPO STATS

44 stars