Skill: guanyang/gif-maker (version 1.0.0)

--- name: gif-maker description: 将序列帧图片或精灵表（Sprite Sheet）转换为高质量 GIF 动画。支持自定义 FPS、布局切分及循环播放。 --- # GIF 动画生成器 (GIF Maker) 本 Skill 旨在帮助用户快速将一组序列帧图片或单张精灵表（Sprite Sheet）转换为 GIF 动画。 ## 核心功能 * **序列帧转 GIF**：支持读取文件夹内的 `png`, `jpg` 序列，按文件名排序合成 GIF。 * **精灵表转 GIF**：支持读取单张 Grid 图片（如 4x4 精灵表），自动切分并合成为 GIF。 * **智能压缩**：支持通过 `--max-size` 参数指定目标文件大小（如 950KB），自动调整压缩参数以满足微信表情包等平台的体积限制。 * **参数控制**： * `--fps`：指定每秒帧数（默认为 12）。 * `--layout`：指定精灵表布局（如 `4x4`），仅在这个模式下需要。 * `--loop`：默认永久循环。 * `--max-size`: 指定最大体积 (KB)。 ## 使用指南 ### 1. 快速开始 无需手动安装依赖，直接运行脚本即可。工具会自动创建虚拟环境 (`.venv`) 并安装所需依赖。 ```bash # 基本用法 1：从文件夹读取序列帧 ./skills/gif-maker/scripts/run.sh /path/to/frame_folder --output my_anim.gif # 基本用法 2：从单张精灵表生成 (需要指定布局) ./skills/gif-maker/scripts/run.sh /path/to/sheet.png --layout 4x4 --output my_sheet_anim.gif # 常用选项： # - 指定 FPS (例如 24) ./skills/gif-maker/scripts/run.sh /path/to/folder --fps 24 # - 开启自动压缩 (限制文件大小在 950KB 以内) ./skills/gif-maker/scripts/run.sh /path/to/folder --max-size 950 ``` ### 2. (可选) 手动安装 如果您希望手动管理环境（注意：压缩功能依赖 `gifsicle` 工具，请确保系统已安装）： - macOS: `brew install gifsicle` - Ubuntu: `sudo apt-get install gifsicle` ```bash python3 -m venv skills/gif-maker/.venv source skills/gif-maker/.venv/bin/activate pip install -r skills/gif-maker/requirements.txt python3 skills/gif-maker/scripts/make_gif.py ... ``` ## 参数说明 * `source`: 输入路径。可以是包含图片的文件夹，也可以是单张图片文件。 * `--output` (`-o`): 输出 GIF 文件名，默认为 `output.gif`。 * `--fps`: 帧率，默认为 12。 * `--layout`: 仅当 `source` 为单文件时使用，格式为 `行数x列数` (如 `4x4`)。 * `--max-size`: (可选) 启用 GIF 压缩，指定目标文件最大大小 (KB)。仅当原始文件超过此大小时才会尝试压缩。 ## 示例 假设你有一个文件夹 `frames/` 包含 `01.png`, `02.png` ... `10.png`： ```bash ./skills/gif-maker/scripts/run.sh frames/ -o animation.gif --fps 15 ``` 假设你有一张 `sheet.png` 是 4x4 的动作序列，且需要生成符合微信表情包规范（<1MB）的 GIF： ```bash ./skills/gif-maker/scripts/run.sh sheet.png --layout 4x4 -o action.gif --max-size 950 ```

path: requirements.txt
content: Pillow>=9.0.0


path: README.md
content: # GIF Maker (GIF 动画生成助手)

这是一个专为 Agent 流程设计的 Skill，旨在快速将序列帧图片或精灵表（Grid Sprite Sheet）转换为 GIF 动画，并支持针对微信表情包等场景的智能压缩。

## 🧩 核心原理

本 Skill 基于 Python 的 `Pillow` 和 `gifsicle` (可选) 构建，核心处理流程如下：

1.  **灵活的输入源处理 (Input Handling)**:
    *   **序列帧模式**: 自动扫描指定目录下的 `png`/`jpg` 图片，按文件名自然排序作为动画帧。
    *   **精灵表模式**: 支持读取单张大图（如 4x4 网格图），通过 `--layout` 参数将其切分为独立的帧序列。

2.  **动画合成 (Animation Composition)**:
    *   根据用户指定的帧率 (`--fps`)，精确计算每一帧的持续时间。
    *   使用 Pillow 库构建动画，设置循环模式（默认为永久循环）和处理方式（Disposal Method 为 Background，避免透明图层重叠残留）。

3.  **智能压缩 (Smart Compression)**:
    *   (集成 `gifsicle`) 针对微信表情包限制（通常需 <1MB 或 <500KB），提供 `--max-size` 参数。
    *   当输出文件超标时，系统通过二分逼近或预设梯度（Lossy 强度、尺寸缩放）自动尝试压缩，直到文件体积达标，无需人工反复试错。
    *   *注：此功能依赖系统安装 `gifsicle` 工具。*

## 📂 目录结构

Skill 的文件组织结构如下：

```text
skills/gif-maker/
├── README.md               # 本说明文档
├── SKILL.md                # Agent Skill 定义及规范引用
├── requirements.txt        # Python 依赖 (Pillow)
└── scripts/
    ├── make_gif.py         # 核心处理脚本 (包含压缩逻辑)
    └── run.sh              # 自动环境配置与执行入口
```

## 💡 最佳实践

### 配合 WeChat Sticker Maker 使用
通常我们使用 `wechat-sticker-maker` 切分好的静态图序列来生成 GIF：

1.  **切分**: 先用 `wechat-sticker-maker` 将九宫格切分为 `sticker_output/main/` 下的序列图。
2.  **合成**: 再用本 Skill 指向该目录生成 GIF。
    ```bash
    ./skills/gif-maker/scripts/run.sh sticker_output/main/ -o my_anim.gif --fps 10
    ```

### 精灵表直接生成
如果不需要中间的静态图，也可以直接从原图生成：
```bash
# 从 4x4 原图直接生成 25fps 的 GIF
./skills/gif-maker/scripts/run.sh origin_grid.png --layout 4x4 --fps 25 --output direct_anim.gif
```

### 控制体积
为微信表情包制作动画时，务必加上体积限制：
```bash
./skills/gif-maker/scripts/run.sh input_frames/ --max-size 950
```

## 🗣️ 自然语言调用示例

在 Agent 环境中，你可以使用自然语言直接调用此能力：

### 场景一：序列帧合成
**User**: "把 `frame_folder` 文件夹里的图片合成一个 GIF，每秒播放 10 帧。"
**Agent**: (运行脚本 `--fps 10`)

### 场景二：制作表情包动画并压缩
**User**: "用这个 4x4 的图 `motion.png` 做一个 GIF，要做成微信表情包那种，不要超过 1M。"
**Agent**: (运行脚本 `--layout 4x4 --max-size 1000`)

---

## 🛠️ 命令行使用

建议通过 `run.sh` 脚本运行，它会自动管理 Python 虚拟环境：

```bash
# 基础用法
./skills/gif-maker/scripts/run.sh /path/to/frames_dir -o output.gif

# 高级用法 (布局切分 + FPS + 压缩)
./skills/gif-maker/scripts/run.sh /path/to/sheet.png --layout 4x4 --fps 24 --max-size 1024
```


path: scripts/run.sh
content: #!/bin/bash
set -e

# Get the directory where the script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
VENV_DIR="$SKILL_DIR/.venv"

# Check if python3 is available
if ! command -v python3 &> /dev/null; then
    echo "Error: python3 is not installed."
    exit 1
fi

# Create virtual environment if it doesn't exist
if [ ! -d "$VENV_DIR" ]; then
    echo "Creating virtual environment..."
    python3 -m venv "$VENV_DIR"
    
    # Install dependencies
    source "$VENV_DIR/bin/activate"
    pip install --upgrade pip
    if [ -f "$SKILL_DIR/requirements.txt" ]; then
        pip install -r "$SKILL_DIR/requirements.txt"
    fi
else
    source "$VENV_DIR/bin/activate"
fi

# Run the python script with all passed arguments
python3 "$SCRIPT_DIR/make_gif.py" "$@"


path: scripts/make_gif.py
content: import os
import sys
import glob
import argparse
import subprocess
from PIL import Image


def compress_gif(input_path, output_path, target_size_kb=950):
    """
    Compress GIF consistently using gifsicle via subprocess.
    """
    if not os.path.exists(input_path):
        print(f"[ERROR] {input_path} not found for compression.")
        return

    # Check initial size
    initial_size = os.path.getsize(input_path) / 1024
    print(f"[INFO] Initial size: {initial_size:.2f} KB")

    if initial_size <= target_size_kb:
        print(
            f"[INFO] Skipping compression. Size is already within target ({target_size_kb} KB)."
        )
        return

    print(f"[INFO] Compressing to under {target_size_kb} KB...")

    # Optimization levels and lossy values to try
    attempts = [
        # (optimize level, lossy factor, scale)
        (3, 30, 1.0),
        (3, 80, 1.0),
        (3, 120, 1.0),
        (3, 200, 1.0),
        (3, 80, 0.9),
        (3, 120, 0.9),
        (3, 200, 0.8),
    ]

    # Temporary file for compression attempts
    temp_output = output_path + ".tmp.gif"

    for opt_level, lossy, scale in attempts:
        cmd = ["gifsicle", "-O" + str(opt_level)]

        if lossy > 0:
            cmd.extend(["--lossy=" + str(lossy)])

        if scale < 1.0:
            cmd.extend(["--scale", str(scale)])

        cmd.extend([input_path, "-o", temp_output])

        try:
            subprocess.run(
                cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
            )

            new_size = os.path.getsize(temp_output) / 1024
            print(
                f"  > Attempt (O{opt_level}, lossy={lossy}, scale={scale}): {new_size:.2f} KB"
            )

            if new_size <= target_size_kb:
                print(f"[SUCCESS] Compressed to {new_size:.2f} KB")
                os.replace(temp_output, output_path)
                return

        except Exception as e:
            print(f"[WARN] Error during compression attempt: {e}")
            if os.path.exists(temp_output):
                os.remove(temp_output)

    print(
        "[WARN] Could not compress to target size with current settings. Keeping original."
    )
    if os.path.exists(temp_output):
        os.remove(temp_output)


def create_gif(source, output_path, fps=12, layout=None, max_size_kb=0):
    """
    Create a GIF from a sequence of images or a sprite sheet.
    """
    frames = []

    # Check if source is directory or file
    if os.path.isdir(source):
        print(f"[INFO] Source is a directory: {source}")
        # Find images in the directory
        image_files = sorted(
            glob.glob(os.path.join(source, "*.png"))
            + glob.glob(os.path.join(source, "*.jpg"))
            + glob.glob(os.path.join(source, "*.jpeg"))
        )

        if not image_files:
            print(f"[ERROR] No images found in {source}")
            sys.exit(1)

        print(f"[INFO] Found {len(image_files)} images.")
        for img_path in image_files:
            try:
                img = Image.open(img_path).convert("RGBA")
                frames.append(img)
            except Exception as e:
                print(f"[WARN] Skipped {img_path}: {e}")

    elif os.path.isfile(source):
        print(f"[INFO] Source is a file: {source}")
        if not layout:
            print(
                "[ERROR] When source is a single file (sprite sheet), you MUST provide --layout (e.g., 4x4)."
            )
            sys.exit(1)

        try:
            rows, cols = map(int, layout.lower().split("x"))
        except ValueError:
            print("[ERROR] Layout format must be 'RowsxCols' (e.g., 4x4).")
            sys.exit(1)

        try:
            sheet = Image.open(source).convert("RGBA")
            sheet_w, sheet_h = sheet.size
            tile_w = sheet_w // cols
            tile_h = sheet_h // rows

            print(
                f"[INFO] Slicing {source} ({sheet_w}x{sheet_h}) into {rows}x{cols} grid (Tile: {tile_w}x{tile_h})."
            )

            for row in range(rows):
                for col in range(cols):
                    left = col * tile_w
                    top = row * tile_h
                    right = left + tile_w
                    bottom = top + tile_h

                    frame = sheet.crop((left, top, right, bottom))
                    frames.append(frame)

        except Exception as e:
            print(f"[ERROR] Failed to process sprite sheet: {e}")
            sys.exit(1)

    else:
        print(f"[ERROR] Source not found: {source}")
        sys.exit(1)

    if not frames:
        print("[ERROR] No frames to save.")
        sys.exit(1)

    # Calculate duration per frame in milliseconds
    duration_ms = int(1000 / fps)

    print(
        f"[INFO] Saving GIF to {output_path} (FPS: {fps}, Duration per frame: {duration_ms}ms, Loop: Forever)"
    )

    # Save GIF
    frames[0].save(
        output_path,
        save_all=True,
        append_images=frames[1:],
        optimize=False,
        duration=duration_ms,
        loop=0,
        disposal=2,  # Restore to background color
    )
    print(f"[SUCCESS] Created {output_path}")

    # Compression logic
    if max_size_kb > 0:
        compress_gif(output_path, output_path, target_size_kb=max_size_kb)


def main():
    parser = argparse.ArgumentParser(
        description="Generate a GIF from a sprite sheet or a sequence of images."
    )
    parser.add_argument(
        "source", help="Path to a directory of images OR a single sprite sheet file."
    )
    parser.add_argument(
        "--output",
        "-o",
        default="output.gif",
        help="Output GIF filename (default: output.gif).",
    )
    parser.add_argument(
        "--fps", type=int, default=12, help="Frames per second (default: 12)."
    )
    parser.add_argument(
        "--layout",
        help="Grid layout for sprite sheet (e.g., '4x4'). Required if source is a file.",
    )
    parser.add_argument(
        "--max-size",
        type=int,
        default=0,
        help="Maximum size in KB to compress the GIF (default: 0, disabled).",
    )

    args = parser.parse_args()

    create_gif(args.source, args.output, args.fps, args.layout, args.max_size)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)