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>
360 lines
10 KiB
Python
360 lines
10 KiB
Python
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
|