feat: add GIF animation packer

This commit is contained in:
2026-05-09 11:45:04 +08:00
parent cbda862c0c
commit df9b77613d
6 changed files with 350 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Python bytecode and caches
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Virtual environments and local environment files
.venv/
venv/
env/
.env
.env.*
# Packaging and build output
build/
dist/
*.egg-info/
# Generated animation conversion output
frames/
littlefs/anim/
# Local LVGL checkout used by the converter
lvgl/
# Editor and OS noise
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db
# Local Codex/agent workspace metadata
.agents/
.codex/

100
AGENTS.md
View File

@@ -0,0 +1,100 @@
# AGENTS.md
本文件给在本仓库中工作的编码代理使用。修改代码前先阅读 `README.md` 和当前脚本实现,保持改动小而直接。
## 项目概览
`EmbeddedAnimPacker` 是一个 Python 命令行工具,用于:
1. 读取输入 GIF。
2. 使用 Pillow 拆分为逐帧 PNG。
3. 调用本地 LVGL 仓库中的 `scripts/LVGLImage.py`
4. 生成适合放入 LittleFS 的 LVGL BIN 图片文件。
主入口是 `EmbeddedAnimPacker.py`
## 重要文件
- `EmbeddedAnimPacker.py`: CLI 主程序。
- `requirements.txt`: Python 运行依赖,目前只有 `Pillow`
- `README.md`: 面向用户的使用说明。
- `default.gif`: 示例输入素材。
- `frames/`: `--keep-frames` 生成的中间 PNG 帧,通常不要提交。
- `littlefs/anim/`: 默认 BIN 输出目录,通常不要提交。
- `lvgl/`: 本地 LVGL checkout作为外部工具目录使用通常不要提交。
## 本地环境
推荐使用虚拟环境:
```bash
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -r requirements.txt
```
运行完整转换需要本地存在 LVGL 仓库,并确保 `lvgl/scripts/LVGLImage.py` 可用。
## 常用命令
安装依赖:
```bash
python3 -m pip install -r requirements.txt
```
运行转换:
```bash
python3 EmbeddedAnimPacker.py default.gif
```
指定 LVGL 和输出目录:
```bash
python3 EmbeddedAnimPacker.py default.gif \
--lvgl-dir ./lvgl \
--output-dir ./littlefs/anim \
--cf RGB565 \
--compress RLE
```
保留中间帧:
```bash
python3 EmbeddedAnimPacker.py default.gif --keep-frames ./frames
```
只检查 CLI 参数和基础语法时,可运行:
```bash
python3 -m py_compile EmbeddedAnimPacker.py
python3 EmbeddedAnimPacker.py --help
```
## 代码约定
- 使用 Python 标准库和 `pathlib.Path` 处理路径。
- 保持 CLI 参数清晰,新增参数时同步更新 `README.md`
- 对用户输入、文件路径和外部工具缺失给出明确错误信息。
- 调用外部命令时使用参数列表,不拼接 shell 字符串。
- 默认行为应避免留下临时文件;只有用户传入 `--keep-frames` 时才保留中间 PNG。
- 不要引入重量级依赖,除非能明显简化核心流程。
## 版本控制注意事项
- 不要提交 `.venv/``frames/``littlefs/anim/``lvgl/`、缓存目录或本地编辑器文件。
- Commit messages must be written in English and follow Conventional Commits 1.0.0, for example `feat: add frame prefix option` or `fix: validate GIF input path`.
- 如果需要新增示例输入素材,确认文件大小合理,并在 `README.md` 中说明用途。
- 如果修改默认输出位置、颜色格式或压缩格式,请同步检查 `.gitignore` 和文档。
## 验证建议
没有完整 LVGL 环境时,至少运行:
```bash
python3 -m py_compile EmbeddedAnimPacker.py
python3 EmbeddedAnimPacker.py --help
```
有完整 LVGL 环境时,使用小 GIF 做一次端到端转换,并检查输出目录中的 `.bin` 文件数量是否等于 GIF 帧数。

174
EmbeddedAnimPacker.py Normal file
View File

@@ -0,0 +1,174 @@
from __future__ import annotations
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
DEFAULT_LVGL_DIR = Path("lvgl")
DEFAULT_OUTPUT_DIR = Path("littlefs/anim")
DEFAULT_COLOR_FORMAT = "RGB565"
DEFAULT_COMPRESS = "RLE"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Extract frames from a GIF and convert them to LVGL BIN images."
)
parser.add_argument("gif", type=Path, help="Input GIF file.")
parser.add_argument(
"--lvgl-dir",
type=Path,
default=DEFAULT_LVGL_DIR,
help=f"LVGL directory. Default: {DEFAULT_LVGL_DIR}",
)
parser.add_argument(
"-o",
"--output-dir",
type=Path,
default=DEFAULT_OUTPUT_DIR,
help=f"Output directory for generated .bin files. Default: {DEFAULT_OUTPUT_DIR}",
)
parser.add_argument(
"--cf",
"--color-format",
dest="color_format",
default=DEFAULT_COLOR_FORMAT,
help=f"LVGL color format. Default: {DEFAULT_COLOR_FORMAT}",
)
parser.add_argument(
"--compress",
default=DEFAULT_COMPRESS,
choices=("RLE", "LZ4", "NONE"),
help=f"LVGL compression mode. Default: {DEFAULT_COMPRESS}",
)
parser.add_argument(
"--prefix",
default=None,
help="Frame filename prefix. Default: input GIF stem.",
)
parser.add_argument(
"--keep-frames",
type=Path,
default=None,
help="Keep extracted PNG frames in this directory instead of using a temp directory.",
)
parser.add_argument(
"--python",
default=sys.executable,
help="Python executable used to run LVGLImage.py. Default: current interpreter.",
)
return parser.parse_args()
def require_valid_input(gif_path: Path, lvgl_dir: Path) -> Path:
if not gif_path.is_file():
raise FileNotFoundError(f"GIF file not found: {gif_path}")
if gif_path.suffix.lower() != ".gif":
raise ValueError(f"Input must be a .gif file: {gif_path}")
converter = lvgl_dir / "scripts" / "LVGLImage.py"
if not converter.is_file():
raise FileNotFoundError(f"LVGL image converter not found: {converter}")
return converter
def extract_gif_frames(gif_path: Path, output_dir: Path, prefix: str) -> list[Path]:
try:
from PIL import Image, ImageSequence
except ImportError as exc:
raise RuntimeError(
"Pillow is required to read GIF files. Install it with: "
"python3 -m pip install Pillow"
) from exc
output_dir.mkdir(parents=True, exist_ok=True)
frame_paths: list[Path] = []
with Image.open(gif_path) as gif:
for index, frame in enumerate(ImageSequence.Iterator(gif)):
frame_path = output_dir / f"{prefix}_{index:04d}.png"
frame.convert("RGBA").save(frame_path)
frame_paths.append(frame_path)
if not frame_paths:
raise RuntimeError(f"No frames found in GIF: {gif_path}")
return frame_paths
def convert_frame(
frame_path: Path,
converter: Path,
output_dir: Path,
color_format: str,
compress: str,
python_executable: str,
) -> None:
print("Converting:", frame_path)
subprocess.run(
[
python_executable,
str(converter),
"--ofmt",
"BIN",
"--cf",
color_format,
"--compress",
compress,
"-o",
str(output_dir),
str(frame_path),
],
check=True,
)
def main() -> int:
args = parse_args()
prefix = args.prefix or args.gif.stem
converter = require_valid_input(args.gif, args.lvgl_dir)
args.output_dir.mkdir(parents=True, exist_ok=True)
if args.keep_frames:
frame_dir = args.keep_frames
frame_paths = extract_gif_frames(args.gif, frame_dir, prefix)
for frame_path in frame_paths:
convert_frame(
frame_path,
converter,
args.output_dir,
args.color_format,
args.compress,
args.python,
)
else:
with tempfile.TemporaryDirectory(prefix="embedded_anim_") as temp_dir:
frame_paths = extract_gif_frames(args.gif, Path(temp_dir), prefix)
for frame_path in frame_paths:
convert_frame(
frame_path,
converter,
args.output_dir,
args.color_format,
args.compress,
args.python,
)
print(f"Done. Converted {len(frame_paths)} frame(s) to {args.output_dir}.")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc

View File

@@ -0,0 +1,35 @@
# EmbeddedAnimPacker
把 GIF 动画拆成 PNG 帧,并逐帧调用 LVGL 的 `LVGLImage.py` 生成 BIN 文件。
## 使用
先安装 GIF 读取依赖:
```bash
python3 -m pip install -r requirements.txt
```
然后传入 GIF 文件:
```bash
python3 EmbeddedAnimPacker.py boot.gif
```
默认会调用 `lvgl/scripts/LVGLImage.py`,输出到 `littlefs/anim`
常用参数:
```bash
python3 EmbeddedAnimPacker.py boot.gif \
--lvgl-dir ./lvgl \
--output-dir ./littlefs/anim \
--cf RGB565 \
--compress RLE
```
保留中间 PNG 帧:
```bash
python3 EmbeddedAnimPacker.py boot.gif --keep-frames ./frames
```

BIN
default.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
Pillow