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`
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 主程序。
- `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(...))` 验证非交互逻辑。

View File

@@ -1,23 +1,55 @@
from __future__ import annotations
import argparse
import contextlib
import subprocess
import sys
import tempfile
from dataclasses import dataclass, replace
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,258 @@ 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
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__":
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

View File

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

View File

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