Files
EmbeddedAnimPacker/EmbeddedAnimPacker.py
Bairan.Zhang 8b785616d8 feat: add interactive configuration menu
Running without a GIF argument (or with -i/--interactive) now launches a
questionary-based menu that selects a source GIF from the current directory
and configures every conversion parameter before running. Passing a GIF on
the command line behaves exactly as before.

- extract a unified run_conversion(ConversionConfig) pipeline shared by the
  CLI and the menu, removing the duplicated convert loop in main()
- make the gif positional optional and add -i/--interactive
- import questionary lazily inside the menu so plain CLI usage gains no hard
  dependency, with a clear install hint when it is missing
- add __version__ / --version (0.2.0) and document the menu in README/AGENTS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 14:47:26 +08:00

360 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import argparse
import contextlib
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
__version__ = "0.2.0"
DEFAULT_LVGL_DIR = Path("lvgl")
DEFAULT_OUTPUT_DIR = Path("littlefs/anim")
DEFAULT_COLOR_FORMAT = "RGB565"
DEFAULT_COMPRESS = "RLE"
COMPRESS_CHOICES = ("RLE", "LZ4", "NONE")
COMMON_COLOR_FORMATS = ("RGB565", "RGB565A8", "RGB888", "ARGB8888", "XRGB8888")
@dataclass
class ConversionConfig:
"""All inputs needed for one GIF -> LVGL BIN conversion run."""
gif: Path
lvgl_dir: Path = DEFAULT_LVGL_DIR
output_dir: Path = DEFAULT_OUTPUT_DIR
color_format: str = DEFAULT_COLOR_FORMAT
compress: str = DEFAULT_COMPRESS
prefix: str | None = None
keep_frames: Path | None = None
python: str = sys.executable
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,
nargs="?",
default=None,
help="Input GIF file. Omit to launch the interactive menu.",
)
parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="Launch the interactive configuration menu (used automatically when no GIF is given).",
)
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=COMPRESS_CHOICES,
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.",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}",
)
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 run_conversion(cfg: ConversionConfig) -> int:
"""Validate inputs, extract frames and convert each one to an LVGL BIN."""
prefix = cfg.prefix or cfg.gif.stem
converter = require_valid_input(cfg.gif, cfg.lvgl_dir)
cfg.output_dir.mkdir(parents=True, exist_ok=True)
with contextlib.ExitStack() as stack:
if cfg.keep_frames is not None:
frame_dir = cfg.keep_frames
else:
frame_dir = Path(
stack.enter_context(
tempfile.TemporaryDirectory(prefix="embedded_anim_")
)
)
frame_paths = extract_gif_frames(cfg.gif, frame_dir, prefix)
for frame_path in frame_paths:
convert_frame(
frame_path,
converter,
cfg.output_dir,
cfg.color_format,
cfg.compress,
cfg.python,
)
print(f"Done. Converted {len(frame_paths)} frame(s) to {cfg.output_dir}.")
return 0
# --------------------------------------------------------------------------- #
# Interactive menu
# --------------------------------------------------------------------------- #
class _MenuCancelled(Exception):
"""Raised when the user aborts the interactive menu (Ctrl-C / quit)."""
def _ask(question):
"""Run a questionary prompt; treat a None answer (Ctrl-C) as cancellation."""
answer = question.ask()
if answer is None:
raise _MenuCancelled
return answer
def discover_gifs() -> list[Path]:
"""List *.gif files in the current working directory."""
return sorted(Path.cwd().glob("*.gif"))
def _select_gif(questionary) -> Path:
manual = "✏️ 手动输入路径…"
quit_choice = "退出"
while True:
by_name = {gif.name: gif for gif in discover_gifs()}
if not by_name:
questionary.print(
"当前目录没有找到 .gif 文件,请手动输入路径。", style="fg:yellow"
)
answer = _ask(
questionary.select(
"选择源 GIF当前目录",
choices=[*by_name, manual, quit_choice],
)
)
if answer == quit_choice:
raise _MenuCancelled
if answer == manual:
candidate = Path(_ask(questionary.path("GIF 路径:"))).expanduser()
else:
candidate = by_name[answer]
if candidate.is_file() and candidate.suffix.lower() == ".gif":
return candidate
questionary.print(f"无效的 GIF 文件:{candidate}", style="bold fg:red")
def run_interactive_menu() -> int:
try:
import questionary
except ImportError:
print(
"交互式菜单需要 questionary。请安装\n"
" python3 -m pip install questionary\n"
"python3 -m pip install -r requirements.txt",
file=sys.stderr,
)
return 1
print("=== EmbeddedAnimPacker 交互式菜单 ===")
try:
gif = _select_gif(questionary)
lvgl_dir = _ask(
questionary.path("LVGL 目录:", default=str(DEFAULT_LVGL_DIR))
)
output_dir = _ask(
questionary.path("输出目录:", default=str(DEFAULT_OUTPUT_DIR))
)
color_format = _ask(
questionary.select(
"颜色格式 (color format)",
choices=[*COMMON_COLOR_FORMATS, "自定义…"],
default=DEFAULT_COLOR_FORMAT,
)
)
if color_format == "自定义…":
color_format = _ask(
questionary.text("自定义颜色格式:", default=DEFAULT_COLOR_FORMAT)
).strip()
compress = _ask(
questionary.select(
"压缩方式 (compress)",
choices=list(COMPRESS_CHOICES),
default=DEFAULT_COMPRESS,
)
)
prefix = _ask(
questionary.text("帧文件名前缀(留空使用 GIF 文件名):", default="")
).strip() or None
keep_frames: Path | None = None
if _ask(questionary.confirm("保留中间 PNG 帧?", default=False)):
keep_frames = Path(
_ask(questionary.path("帧输出目录:", default="frames"))
).expanduser()
cfg = ConversionConfig(
gif=gif,
lvgl_dir=Path(lvgl_dir).expanduser(),
output_dir=Path(output_dir).expanduser(),
color_format=color_format,
compress=compress,
prefix=prefix,
keep_frames=keep_frames,
)
print("\n--- 配置汇总 ---")
print(f" 源 GIF : {cfg.gif}")
print(f" LVGL 目录 : {cfg.lvgl_dir}")
print(f" 输出目录 : {cfg.output_dir}")
print(f" 颜色格式 : {cfg.color_format}")
print(f" 压缩方式 : {cfg.compress}")
print(f" 帧前缀 : {cfg.prefix or cfg.gif.stem}")
print(f" 保留帧 : {cfg.keep_frames or '否(使用临时目录)'}")
print("----------------")
if not _ask(questionary.confirm("确认开始转换?", default=True)):
raise _MenuCancelled
except _MenuCancelled:
print("已取消。")
return 0
return run_conversion(cfg)
def main() -> int:
args = parse_args()
if args.interactive or args.gif is None:
return run_interactive_menu()
cfg = ConversionConfig(
gif=args.gif,
lvgl_dir=args.lvgl_dir,
output_dir=args.output_dir,
color_format=args.color_format,
compress=args.compress,
prefix=args.prefix,
keep_frames=args.keep_frames,
python=args.python,
)
return run_conversion(cfg)
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\n已取消。", file=sys.stderr)
raise SystemExit(130)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc