From 8b785616d8eb778a0fb4d6c4d71f390b2990cc66 Mon Sep 17 00:00:00 2001 From: "Bairan.Zhang" Date: Mon, 1 Jun 2026 14:47:26 +0800 Subject: [PATCH] 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 --- AGENTS.md | 22 +++- EmbeddedAnimPacker.py | 241 +++++++++++++++++++++++++++++++++++++----- README.md | 16 ++- requirements.txt | 1 + 4 files changed, 247 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 31e5351..c89f8dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,12 +11,17 @@ 3. 调用本地 LVGL 仓库中的 `scripts/LVGLImage.py`。 4. 生成适合放入 LittleFS 的 LVGL BIN 图片文件。 -主入口是 `EmbeddedAnimPacker.py`。 +主入口是 `EmbeddedAnimPacker.py`,提供两种用法: + +- **命令行**:传入 GIF 与参数,行为与脚本最初版本一致。 +- **交互式菜单**:不带 GIF 参数(或传 `-i`/`--interactive`)时进入,基于 `questionary` 选择素材并配置参数。 + +两条路径都汇聚到同一个 `run_conversion(ConversionConfig)` 管线,避免逻辑重复。 ## 重要文件 - `EmbeddedAnimPacker.py`: CLI 主程序。 -- `requirements.txt`: Python 运行依赖,目前只有 `Pillow`。 +- `requirements.txt`: Python 运行依赖:`Pillow`(读取 GIF)与 `questionary`(交互式菜单)。 - `README.md`: 面向用户的使用说明。 - `default.gif`: 示例输入素材。 - `frames/`: `--keep-frames` 生成的中间 PNG 帧,通常不要提交。 @@ -43,7 +48,14 @@ python3 -m pip install -r requirements.txt python3 -m pip install -r requirements.txt ``` -运行转换: +进入交互式菜单(不带 GIF 参数,或显式加 `-i`): + +```bash +python3 EmbeddedAnimPacker.py +python3 EmbeddedAnimPacker.py -i +``` + +运行转换(命令行): ```bash python3 EmbeddedAnimPacker.py default.gif @@ -80,6 +92,8 @@ python3 EmbeddedAnimPacker.py --help - 调用外部命令时使用参数列表,不拼接 shell 字符串。 - 默认行为应避免留下临时文件;只有用户传入 `--keep-frames` 时才保留中间 PNG。 - 不要引入重量级依赖,除非能明显简化核心流程。 +- `questionary` 只在 `run_interactive_menu()` 内部按需导入;纯命令行路径不得依赖它,缺失时给出清晰安装提示。 +- 新增转换参数时,同步加到 `ConversionConfig`、命令行参数和交互式菜单三处,并更新 `README.md`。 ## 版本控制注意事项 @@ -99,3 +113,5 @@ python3 EmbeddedAnimPacker.py --help ``` 有完整 LVGL 环境时,使用小 GIF 做一次端到端转换,并检查输出目录中的 `.bin` 文件数量是否等于 GIF 帧数。 + +交互式菜单需要真实 TTY 才能运行(`questionary` 基于 `prompt_toolkit`),不便用管道自动化。无 TTY 时可单独调用 `discover_gifs()` 和 `run_conversion(ConversionConfig(...))` 验证非交互逻辑。 diff --git a/EmbeddedAnimPacker.py b/EmbeddedAnimPacker.py index 847f140..784a27f 100644 --- a/EmbeddedAnimPacker.py +++ b/EmbeddedAnimPacker.py @@ -1,23 +1,55 @@ 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, help="Input GIF file.") + 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, @@ -41,7 +73,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--compress", default=DEFAULT_COMPRESS, - choices=("RLE", "LZ4", "NONE"), + choices=COMPRESS_CHOICES, help=f"LVGL compression mode. Default: {DEFAULT_COMPRESS}", ) parser.add_argument( @@ -60,6 +92,11 @@ def parse_args() -> argparse.Namespace: 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() @@ -128,47 +165,195 @@ def convert_frame( ) -def main() -> int: - args = parse_args() - prefix = args.prefix or args.gif.stem - converter = require_valid_input(args.gif, args.lvgl_dir) +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) - args.output_dir.mkdir(parents=True, exist_ok=True) + cfg.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) + 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, - args.output_dir, - args.color_format, - args.compress, - args.python, + cfg.output_dir, + cfg.color_format, + cfg.compress, + cfg.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}.") + 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 diff --git a/README.md b/README.md index fb5a30c..6dd9d89 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,25 @@ python3 -m venv .venv . .venv/bin/activate ``` -然后安装 GIF 读取依赖: +然后安装依赖(Pillow 用于读取 GIF,questionary 用于交互式菜单): ```bash python3 -m pip install -r requirements.txt ``` -然后传入 GIF 文件: +## 交互式菜单 + +不带任何参数运行,会进入交互式菜单:可以从当前目录选择源 GIF、逐项配置参数,最后确认并执行转换: + +```bash +python3 EmbeddedAnimPacker.py +``` + +也可以用 `-i` / `--interactive` 显式进入菜单。菜单仅在进入时才需要 `questionary`;纯命令行用法不依赖它。 + +## 命令行 + +直接传入 GIF 文件即可(行为与以前一致): ```bash python3 EmbeddedAnimPacker.py boot.gif diff --git a/requirements.txt b/requirements.txt index 7e2fba5..fb5f6d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ Pillow +questionary