
Sprite Animation Assets
- 55 installs
- 58 repo stars
- Updated May 7, 2026
- dwdestiny/codex-visual-asset-skills
Plans and generates animation frame sets, sprite sheets, and GIF previews with consistent character identity for games and web apps.
About
A skill that orchestrates creating game and web animation assets: it plans, generates, QAs, and packages continuous frame sets, sprite sheets, and GIF previews while keeping a character's identity, size, and transparent background consistent across frames. A solo builder reaches for it to produce loopable sprite animations without hand-drawing every frame.
- Generate continuous animation frame sets
- Compose PNG frames into sprite atlases
- Produce GIF previews with transparent, loopable motion
Sprite Animation Assets by the numbers
- 55 all-time installs (skills.sh)
- Ranked #874 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dwdestiny/codex-visual-asset-skills --skill sprite-animation-assetsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 55 |
|---|---|
| repo stars | ★ 58 |
| Last updated | May 7, 2026 |
| Repository | dwdestiny/codex-visual-asset-skills ↗ |
What it does
Plans and generates animation frame sets, sprite sheets, and GIF previews with consistent character identity for games and web apps.
Who is it for?
Indie game and web builders needing sprite animations
Skip if: 3D or vector motion graphics
Files
Sprite Animation Assets
核心判断
动画素材的关键不是“单张图好看”,而是同一个角色或物体在每一帧里保持身份一致、尺寸一致、背景透明、动作能循环。先定义帧规格和状态,再逐组生图,最后用脚本组装和验收。
适合输出:连续帧 PNG、单状态 GIF、透明 spritesheet、固定网格 atlas、网页/游戏动画素材。
不适合输出:单张透明素材;这类转给 $transparent-visual-assets。
工作流
1. 定义规格:状态名、每个状态帧数、画布尺寸、输出格式。 2. 生成或确认一张 canonical base image,作为所有帧的身份锚点。 3. 分状态生成连续帧;每组帧都要使用同一角色参考和同一纯色背景或透明背景策略。 4. 目视 QA:身份一致、动作连贯、无漂浮杂点、无文字、无阴影地面、无裁切。 5. 如需要,先用 $transparent-visual-assets 把每帧处理成透明 PNG。 6. 用 scripts/compose_sprite_set.py 组合 atlas,并可导出每个状态的 GIF 预览。 7. 检查 sprite-set-report.json,再打开 atlas/GIF 做最终验收。
生图提示词要点
每组动画都要锁定身份:
Create <frame_count> separated frames of the same <subject> for a looping sprite animation.
Keep the same silhouette, proportions, face, palette, outline style, and accessories in every frame.
Use a flat removable background color <background_color>, with no shadows, text, scenery, motion trails, frame numbers, grid lines, or detached decorative effects.
Each pose must be fully visible, separated from the next pose, and safe to crop into a transparent frame.
Action: <state_action>.如果需要兼容 Codex 自定义宠物的 atlas 规格,先读 references/sprite-atlas-layouts.md,使用官方 hatch-pet 拆出的 8x9、192x208、9 行状态口径。
组装脚本
准备 manifest:
{
"cell_width": 192,
"cell_height": 208,
"columns": 8,
"states": [
{
"name": "idle",
"row": 0,
"frames": ["idle_000.png", "idle_001.png"],
"durations_ms": [120, 120]
}
]
}运行:
python "${CODEX_HOME:-$HOME/.codex}/skills/sprite-animation-assets/scripts/compose_sprite_set.py" \
--manifest /absolute/path/manifest.json \
--frames-dir /absolute/path/frames \
--output-atlas /absolute/path/spritesheet.png \
--output-dir /absolute/path/qa \
--gif脚本会:
- 把每帧居中放入固定 cell。
- 保持未使用 cell 全透明。
- 输出
sprite-set-report.json。 - 使用
--gif时,为每个状态导出一个 GIF 预览。
验收标准
- 每个状态帧数符合 manifest。
- 同一角色/物体在所有帧里保持同一身份,不变物种、不变脸、不变配色。
- 所有帧都是透明背景或可干净抠透明背景。
- 动作能循环,首尾不明显跳变。
- spritesheet 未使用格子必须完全透明。
- GIF 只是预览;最终网页/游戏素材优先交付 PNG 帧或透明 atlas。
interface:
display_name: "Animation Sprite Set"
short_description: "Plan and package continuous animation sprites"
default_prompt: "Use $sprite-animation-assets to turn this character into a consistent animated sprite set."
Sprite Atlas 动画行参考
这份参考来自官方 hatch-pet skill 的素材处理规格,用于需要兼容 Codex 自定义宠物 atlas 时。普通网页、游戏或产品动画不必强制使用这套 8x9 atlas。
Atlas 规格
- 尺寸:
1536x1872 - 网格:8 列 x 9 行
- 单格:
192x208 - 背景:透明
- 未使用格子:必须全透明
行定义
| Row | State | Used columns | Durations |
|---|---|---|---|
| 0 | idle | 0-5 | 280, 110, 110, 140, 140, 320 ms |
| 1 | running-right | 0-7 | 120 ms each, final 220 ms |
| 2 | running-left | 0-7 | 120 ms each, final 220 ms |
| 3 | waving | 0-3 | 140 ms each, final 280 ms |
| 4 | jumping | 0-4 | 140 ms each, final 280 ms |
| 5 | failed | 0-7 | 140 ms each, final 240 ms |
| 6 | waiting | 0-5 | 150 ms each, final 260 ms |
| 7 | running | 0-5 | 120 ms each, final 220 ms |
| 8 | review | 0-5 | 150 ms each, final 280 ms |
状态语义
idle:低干扰呼吸/眨眼,第一帧要能当静态 reduced-motion 形态。running-right:向右移动,靠身体、肢体和道具运动表达,不要速度线和尘土。running-left:向左移动;只有角色左右对称时才可由 right 镜像。waving:靠手/爪姿态表达,不要波浪线和符号。jumping:靠身体高度和姿态表达,不要影子、落地尘、冲击线。failed:失败/沮丧/眩晕,可有贴在主体上的小泪珠、烟雾或星星,不要漂浮符号。waiting:等待时的小动作,区别于 idle。running:任务执行中,不是脚步跑步,不要方向位移。review:审查/思考/聚焦,靠眼神、倾身、头部动作表达。
QA 红线
- 不能有文字、标签、帧号、网格线、UI、代码片段。
- 不能有白底、黑底、棋盘格背景、场景背景。
- 不能有投影、地面、光晕、半透明拖影、漂浮粒子。
- 不能跨格、裁切、混入相邻帧残片。
- 不能把同一张图简单缩放/旋转当成动画。
#!/usr/bin/env python3
"""Compose transparent frames into a spritesheet atlas and optional GIF previews."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from PIL import Image
def load_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001
raise SystemExit(f"could not read manifest JSON: {exc}") from exc
if not isinstance(value, dict):
raise SystemExit("manifest must be a JSON object")
return value
def positive_int(value: Any, *, field: str) -> int:
if not isinstance(value, int) or value <= 0:
raise SystemExit(f"{field} must be a positive integer")
return value
def non_negative_int(value: Any, *, field: str) -> int:
if not isinstance(value, int) or value < 0:
raise SystemExit(f"{field} must be a non-negative integer")
return value
def resolve_frame_path(raw_value: Any, *, frames_dir: Path) -> Path:
if not isinstance(raw_value, str) or not raw_value.strip():
raise SystemExit("frame path must be a non-empty string")
path = Path(raw_value).expanduser()
if not path.is_absolute():
path = frames_dir / path
path = path.resolve()
if not path.is_file():
raise SystemExit(f"frame image does not exist: {path}")
return path
def fit_frame_to_cell(frame_image: Image.Image, *, cell_width: int, cell_height: int) -> Image.Image:
image = frame_image.convert("RGBA")
bbox = image.getbbox()
output = Image.new("RGBA", (cell_width, cell_height), (0, 0, 0, 0))
if bbox is None:
return output
sprite = image.crop(bbox)
scale = min(cell_width / sprite.width, cell_height / sprite.height, 1.0)
if scale != 1.0:
sprite = sprite.resize(
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
Image.Resampling.LANCZOS,
)
left = (cell_width - sprite.width) // 2
top = (cell_height - sprite.height) // 2
output.alpha_composite(sprite, (left, top))
return output
def alpha_nonzero_count(image: Image.Image) -> int:
alpha = image.getchannel("A")
return sum(alpha.histogram()[1:])
def normalized_states(manifest: dict[str, Any], *, columns: int, frames_dir: Path) -> list[dict[str, Any]]:
states = manifest.get("states")
if not isinstance(states, list) or not states:
raise SystemExit("manifest.states must be a non-empty list")
normalized: list[dict[str, Any]] = []
seen_rows: set[int] = set()
seen_names: set[str] = set()
for state in states:
if not isinstance(state, dict):
raise SystemExit("each state must be a JSON object")
name = state.get("name")
if not isinstance(name, str) or not name.strip():
raise SystemExit("state.name must be a non-empty string")
name = name.strip()
if name in seen_names:
raise SystemExit(f"duplicate state name: {name}")
seen_names.add(name)
row = non_negative_int(state.get("row"), field=f"{name}.row")
if row in seen_rows:
raise SystemExit(f"duplicate atlas row: {row}")
seen_rows.add(row)
raw_frames = state.get("frames")
if not isinstance(raw_frames, list) or not raw_frames:
raise SystemExit(f"{name}.frames must be a non-empty list")
if len(raw_frames) > columns:
raise SystemExit(f"{name} has {len(raw_frames)} frames, but atlas columns is {columns}")
frame_paths = [resolve_frame_path(raw_frame, frames_dir=frames_dir) for raw_frame in raw_frames]
durations_ms = state.get("durations_ms")
if durations_ms is None:
durations = [120] * len(frame_paths)
elif (
isinstance(durations_ms, list)
and len(durations_ms) == len(frame_paths)
and all(isinstance(duration, int) and duration > 0 for duration in durations_ms)
):
durations = durations_ms
else:
raise SystemExit(f"{name}.durations_ms must match frame count and contain positive integers")
normalized.append(
{
"name": name,
"row": row,
"frame_paths": frame_paths,
"durations_ms": durations,
}
)
return sorted(normalized, key=lambda item: item["row"])
def save_gif(
frames: list[Image.Image],
*,
durations_ms: list[int],
output_path: Path,
) -> None:
if not frames:
raise SystemExit("cannot save a GIF with no frames")
output_path.parent.mkdir(parents=True, exist_ok=True)
frames[0].save(
output_path,
save_all=True,
append_images=frames[1:],
duration=durations_ms,
loop=0,
disposal=2,
transparency=0,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", required=True, help="JSON manifest describing states and frames.")
parser.add_argument("--frames-dir", required=True, help="Base directory for relative frame paths.")
parser.add_argument("--output-atlas", required=True, help="Output transparent atlas path.")
parser.add_argument("--output-dir", required=True, help="Directory for report and optional GIF previews.")
parser.add_argument("--gif", action="store_true", help="Write one GIF preview per state.")
args = parser.parse_args()
manifest_path = Path(args.manifest).expanduser().resolve()
frames_dir = Path(args.frames_dir).expanduser().resolve()
output_atlas = Path(args.output_atlas).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
if not manifest_path.is_file():
raise SystemExit(f"manifest does not exist: {manifest_path}")
if not frames_dir.is_dir():
raise SystemExit(f"frames directory does not exist: {frames_dir}")
manifest = load_json(manifest_path)
cell_width = positive_int(manifest.get("cell_width"), field="cell_width")
cell_height = positive_int(manifest.get("cell_height"), field="cell_height")
columns = positive_int(manifest.get("columns"), field="columns")
states = normalized_states(manifest, columns=columns, frames_dir=frames_dir)
rows = max(state["row"] for state in states) + 1
atlas = Image.new("RGBA", (columns * cell_width, rows * cell_height), (0, 0, 0, 0))
report_states: list[dict[str, Any]] = []
for state in states:
fitted_frames: list[Image.Image] = []
for column_index, frame_path in enumerate(state["frame_paths"]):
with Image.open(frame_path) as opened_frame:
fitted_frame = fit_frame_to_cell(
opened_frame,
cell_width=cell_width,
cell_height=cell_height,
)
fitted_frames.append(fitted_frame)
atlas.alpha_composite(
fitted_frame,
(column_index * cell_width, state["row"] * cell_height),
)
if args.gif:
save_gif(
fitted_frames,
durations_ms=state["durations_ms"],
output_path=output_dir / f"{state['name']}.gif",
)
report_states.append(
{
"name": state["name"],
"row": state["row"],
"frame_count": len(fitted_frames),
"durations_ms": state["durations_ms"],
"nontransparent_pixels": [alpha_nonzero_count(frame) for frame in fitted_frames],
}
)
output_atlas.parent.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents=True, exist_ok=True)
atlas.save(output_atlas)
result = {
"ok": True,
"manifest": str(manifest_path),
"atlas": {
"path": str(output_atlas),
"width": atlas.width,
"height": atlas.height,
"columns": columns,
"rows": rows,
"cell_width": cell_width,
"cell_height": cell_height,
},
"states": report_states,
"gif": bool(args.gif),
}
(output_dir / "sprite-set-report.json").write_text(
json.dumps(result, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()