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>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user