2 Commits

Author SHA1 Message Date
7cc710967d feat: support multi-select / select-all of source GIFs in the menu
The menu's source step now uses a two-step selection (_select_gifs): pick a
mode — tick several GIFs (questionary.checkbox), all GIFs in the directory at
once, or enter a path manually — then convert them in one batch. Single-GIF
selection and the entire command line stay unchanged.

- add run_conversions(gifs, template) that reuses the unchanged single-GIF
  run_conversion() per item, continues on per-GIF errors, and prints an
  aggregate "Batch complete: X/Y" summary (non-zero exit if any failed)
- with multiple GIFs the per-GIF stem is forced as the frame prefix so frames
  never collide; the prefix prompt is only shown for a single GIF
- update README/AGENTS for the multi/all selection and batch behavior

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:48:19 +08:00
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
4 changed files with 310 additions and 33 deletions

View File

@@ -11,12 +11,17 @@
3. 调用本地 LVGL 仓库中的 `scripts/LVGLImage.py` 3. 调用本地 LVGL 仓库中的 `scripts/LVGLImage.py`
4. 生成适合放入 LittleFS 的 LVGL BIN 图片文件。 4. 生成适合放入 LittleFS 的 LVGL BIN 图片文件。
主入口是 `EmbeddedAnimPacker.py` 主入口是 `EmbeddedAnimPacker.py`,提供两种用法:
- **命令行**:传入 GIF 与参数,行为与脚本最初版本一致。
- **交互式菜单**:不带 GIF 参数(或传 `-i`/`--interactive`)时进入,基于 `questionary` 选择素材并配置参数。`_select_gifs()` 采用两步式选择,返回 GIF 列表,支持多选 / 一键全选 / 手动输入路径。
两条路径都汇聚到同一个 `run_conversion(ConversionConfig)` 单 GIF 管线。命令行直接调用它;菜单经 `run_conversions(gifs, template)` 逐个调用continue-on-error末尾打印聚合结果且多选时强制用各 GIF 自身 stem 作帧前缀以避免冲突。
## 重要文件 ## 重要文件
- `EmbeddedAnimPacker.py`: CLI 主程序。 - `EmbeddedAnimPacker.py`: CLI 主程序。
- `requirements.txt`: Python 运行依赖,目前只有 `Pillow` - `requirements.txt`: Python 运行依赖`Pillow`(读取 GIF`questionary`(交互式菜单)
- `README.md`: 面向用户的使用说明。 - `README.md`: 面向用户的使用说明。
- `default.gif`: 示例输入素材。 - `default.gif`: 示例输入素材。
- `frames/`: `--keep-frames` 生成的中间 PNG 帧,通常不要提交。 - `frames/`: `--keep-frames` 生成的中间 PNG 帧,通常不要提交。
@@ -43,7 +48,14 @@ python3 -m pip install -r requirements.txt
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 ```bash
python3 EmbeddedAnimPacker.py default.gif python3 EmbeddedAnimPacker.py default.gif
@@ -80,6 +92,8 @@ python3 EmbeddedAnimPacker.py --help
- 调用外部命令时使用参数列表,不拼接 shell 字符串。 - 调用外部命令时使用参数列表,不拼接 shell 字符串。
- 默认行为应避免留下临时文件;只有用户传入 `--keep-frames` 时才保留中间 PNG。 - 默认行为应避免留下临时文件;只有用户传入 `--keep-frames` 时才保留中间 PNG。
- 不要引入重量级依赖,除非能明显简化核心流程。 - 不要引入重量级依赖,除非能明显简化核心流程。
- `questionary` 只在 `run_interactive_menu()` 内部按需导入;纯命令行路径不得依赖它,缺失时给出清晰安装提示。
- 新增转换参数时,同步加到 `ConversionConfig`、命令行参数和交互式菜单三处,并更新 `README.md`
## 版本控制注意事项 ## 版本控制注意事项
@@ -99,3 +113,5 @@ python3 EmbeddedAnimPacker.py --help
``` ```
有完整 LVGL 环境时,使用小 GIF 做一次端到端转换,并检查输出目录中的 `.bin` 文件数量是否等于 GIF 帧数。 有完整 LVGL 环境时,使用小 GIF 做一次端到端转换,并检查输出目录中的 `.bin` 文件数量是否等于 GIF 帧数。
交互式菜单需要真实 TTY 才能运行(`questionary` 基于 `prompt_toolkit`),不便用管道自动化。无 TTY 时可单独调用 `discover_gifs()``run_conversion(ConversionConfig(...))` 验证非交互逻辑。

View File

@@ -1,23 +1,55 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import contextlib
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
__version__ = "0.2.0"
DEFAULT_LVGL_DIR = Path("lvgl") DEFAULT_LVGL_DIR = Path("lvgl")
DEFAULT_OUTPUT_DIR = Path("littlefs/anim") DEFAULT_OUTPUT_DIR = Path("littlefs/anim")
DEFAULT_COLOR_FORMAT = "RGB565" DEFAULT_COLOR_FORMAT = "RGB565"
DEFAULT_COMPRESS = "RLE" 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: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Extract frames from a GIF and convert them to LVGL BIN images." 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( parser.add_argument(
"--lvgl-dir", "--lvgl-dir",
type=Path, type=Path,
@@ -41,7 +73,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--compress", "--compress",
default=DEFAULT_COMPRESS, default=DEFAULT_COMPRESS,
choices=("RLE", "LZ4", "NONE"), choices=COMPRESS_CHOICES,
help=f"LVGL compression mode. Default: {DEFAULT_COMPRESS}", help=f"LVGL compression mode. Default: {DEFAULT_COMPRESS}",
) )
parser.add_argument( parser.add_argument(
@@ -60,6 +92,11 @@ def parse_args() -> argparse.Namespace:
default=sys.executable, default=sys.executable,
help="Python executable used to run LVGLImage.py. Default: current interpreter.", 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() return parser.parse_args()
@@ -128,47 +165,258 @@ def convert_frame(
) )
def main() -> int: def run_conversion(cfg: ConversionConfig) -> int:
args = parse_args() """Validate inputs, extract frames and convert each one to an LVGL BIN."""
prefix = args.prefix or args.gif.stem prefix = cfg.prefix or cfg.gif.stem
converter = require_valid_input(args.gif, args.lvgl_dir) 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: with contextlib.ExitStack() as stack:
frame_dir = args.keep_frames if cfg.keep_frames is not None:
frame_paths = extract_gif_frames(args.gif, frame_dir, prefix) 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: for frame_path in frame_paths:
convert_frame( convert_frame(
frame_path, frame_path,
converter, converter,
args.output_dir, cfg.output_dir,
args.color_format, cfg.color_format,
args.compress, cfg.compress,
args.python, 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: print(f"Done. Converted {len(frame_paths)} frame(s) to {cfg.output_dir}.")
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 return 0
def run_conversions(gifs: list[Path], template: ConversionConfig) -> int:
"""Convert each GIF using ``template``'s shared parameters.
With more than one GIF the per-GIF stem is always used as the frame prefix
(so frames from different GIFs never collide). A failure on one GIF is
reported and the rest still run.
"""
multi = len(gifs) > 1
failures: list[Path] = []
for gif in gifs:
cfg = replace(template, gif=gif, prefix=None if multi else template.prefix)
try:
run_conversion(cfg)
except Exception as exc: # noqa: BLE001 - keep batch going, report at end
print(f"Error: {gif}: {exc}", file=sys.stderr)
failures.append(gif)
if multi:
print(
f"\nBatch complete: {len(gifs) - len(failures)}/{len(gifs)} "
f"GIF(s) converted to {template.output_dir}."
)
for gif in failures:
print(f" failed: {gif}", file=sys.stderr)
return 1 if failures else 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 _validate_gif(candidate: Path, questionary) -> bool:
if candidate.is_file() and candidate.suffix.lower() == ".gif":
return True
questionary.print(f"无效的 GIF 文件:{candidate}", style="bold fg:red")
return False
def _select_gifs(questionary) -> list[Path]:
"""Two-step source selection: pick a mode, then the GIF(s).
Returns a non-empty list of GIF paths, or raises _MenuCancelled on quit.
"""
pick = "勾选多个 GIF"
manual = "✏️ 手动输入路径…"
quit_choice = "退出"
while True:
gifs = discover_gifs()
if gifs:
all_choice = f"全部 {len(gifs)} 个 GIF"
choices = [pick, all_choice, manual, quit_choice]
else:
questionary.print(
"当前目录没有找到 .gif 文件,请手动输入路径。", style="fg:yellow"
)
all_choice = None
choices = [manual, quit_choice]
mode = _ask(questionary.select("选择源 GIF", choices=choices))
if mode == quit_choice:
raise _MenuCancelled
if mode == manual:
candidate = Path(_ask(questionary.path("GIF 路径:"))).expanduser()
if _validate_gif(candidate, questionary):
return [candidate]
continue
if mode == all_choice:
return gifs
# mode == pick: multi-select via checkbox
selected = _ask(
questionary.checkbox(
"空格勾选,回车确认:",
choices=[questionary.Choice(gif.name, value=gif) for gif in gifs],
)
)
if not selected:
questionary.print("请至少选择一个 GIF。", style="fg:yellow")
continue
return selected
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:
gifs = _select_gifs(questionary)
single = len(gifs) == 1
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 = None
if single:
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()
template = ConversionConfig(
gif=gifs[0],
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--- 配置汇总 ---")
if single:
print(f" 源 GIF : {gifs[0]}")
else:
print(f" 源 GIF : {len(gifs)}")
for gif in gifs:
print(f" - {gif.name}")
print(f" LVGL 目录 : {template.lvgl_dir}")
print(f" 输出目录 : {template.output_dir}")
print(f" 颜色格式 : {template.color_format}")
print(f" 压缩方式 : {template.compress}")
print(f" 帧前缀 : {template.prefix or gifs[0].stem if single else '各 GIF 文件名'}")
print(f" 保留帧 : {template.keep_frames or '否(使用临时目录)'}")
print("----------------")
if not _ask(questionary.confirm("确认开始转换?", default=True)):
raise _MenuCancelled
except _MenuCancelled:
print("已取消。")
return 0
return run_conversions(gifs, template)
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__": if __name__ == "__main__":
try: try:
raise SystemExit(main()) raise SystemExit(main())
except KeyboardInterrupt:
print("\n已取消。", file=sys.stderr)
raise SystemExit(130)
except Exception as exc: except Exception as exc:
print(f"Error: {exc}", file=sys.stderr) print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc raise SystemExit(1) from exc

View File

@@ -11,13 +11,25 @@ python3 -m venv .venv
. .venv/bin/activate . .venv/bin/activate
``` ```
然后安装 GIF 读取依赖: 然后安装依赖Pillow 用于读取 GIFquestionary 用于交互式菜单)
```bash ```bash
python3 -m pip install -r requirements.txt python3 -m pip install -r requirements.txt
``` ```
然后传入 GIF 文件: ## 交互式菜单
不带任何参数运行,会进入交互式菜单:从当前目录选择源 GIF支持**多选或一键全选**,批量转换)、逐项配置参数,最后确认并执行转换:
```bash
python3 EmbeddedAnimPacker.py
```
也可以用 `-i` / `--interactive` 显式进入菜单。菜单仅在进入时才需要 `questionary`;纯命令行用法不依赖它。
## 命令行
直接传入 GIF 文件即可(行为与以前一致):
```bash ```bash
python3 EmbeddedAnimPacker.py boot.gif python3 EmbeddedAnimPacker.py boot.gif

View File

@@ -1 +1,2 @@
Pillow Pillow
questionary