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>
This commit is contained in:
2026-06-01 18:48:19 +08:00
parent 8b785616d8
commit 7cc710967d
3 changed files with 98 additions and 35 deletions

View File

@@ -14,9 +14,9 @@
主入口是 `EmbeddedAnimPacker.py`,提供两种用法: 主入口是 `EmbeddedAnimPacker.py`,提供两种用法:
- **命令行**:传入 GIF 与参数,行为与脚本最初版本一致。 - **命令行**:传入 GIF 与参数,行为与脚本最初版本一致。
- **交互式菜单**:不带 GIF 参数(或传 `-i`/`--interactive`)时进入,基于 `questionary` 选择素材并配置参数。 - **交互式菜单**:不带 GIF 参数(或传 `-i`/`--interactive`)时进入,基于 `questionary` 选择素材并配置参数。`_select_gifs()` 采用两步式选择,返回 GIF 列表,支持多选 / 一键全选 / 手动输入路径。
两条路径都汇聚到同一个 `run_conversion(ConversionConfig)` 管线,避免逻辑重复 两条路径都汇聚到同一个 `run_conversion(ConversionConfig)` 单 GIF 管线。命令行直接调用它;菜单经 `run_conversions(gifs, template)` 逐个调用continue-on-error末尾打印聚合结果且多选时强制用各 GIF 自身 stem 作帧前缀以避免冲突
## 重要文件 ## 重要文件

View File

@@ -5,7 +5,7 @@ import contextlib
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
@@ -197,6 +197,34 @@ def run_conversion(cfg: ConversionConfig) -> int:
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 # Interactive menu
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -219,31 +247,58 @@ def discover_gifs() -> list[Path]:
return sorted(Path.cwd().glob("*.gif")) return sorted(Path.cwd().glob("*.gif"))
def _select_gif(questionary) -> Path: 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 = "✏️ 手动输入路径…" manual = "✏️ 手动输入路径…"
quit_choice = "退出" quit_choice = "退出"
while True: while True:
by_name = {gif.name: gif for gif in discover_gifs()} gifs = discover_gifs()
if not by_name: if gifs:
all_choice = f"全部 {len(gifs)} 个 GIF"
choices = [pick, all_choice, manual, quit_choice]
else:
questionary.print( questionary.print(
"当前目录没有找到 .gif 文件,请手动输入路径。", style="fg:yellow" "当前目录没有找到 .gif 文件,请手动输入路径。", style="fg:yellow"
) )
answer = _ask( all_choice = None
questionary.select( choices = [manual, quit_choice]
"选择源 GIF当前目录",
choices=[*by_name, 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 answer == quit_choice: if not selected:
raise _MenuCancelled questionary.print("请至少选择一个 GIF。", style="fg:yellow")
if answer == manual: continue
candidate = Path(_ask(questionary.path("GIF 路径:"))).expanduser() return selected
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: def run_interactive_menu() -> int:
@@ -261,7 +316,8 @@ def run_interactive_menu() -> int:
print("=== EmbeddedAnimPacker 交互式菜单 ===") print("=== EmbeddedAnimPacker 交互式菜单 ===")
try: try:
gif = _select_gif(questionary) gifs = _select_gifs(questionary)
single = len(gifs) == 1
lvgl_dir = _ask( lvgl_dir = _ask(
questionary.path("LVGL 目录:", default=str(DEFAULT_LVGL_DIR)) questionary.path("LVGL 目录:", default=str(DEFAULT_LVGL_DIR))
@@ -290,9 +346,11 @@ def run_interactive_menu() -> int:
) )
) )
prefix = _ask( prefix = None
questionary.text("帧文件名前缀(留空使用 GIF 文件名):", default="") if single:
).strip() or None prefix = _ask(
questionary.text("帧文件名前缀(留空使用 GIF 文件名):", default="")
).strip() or None
keep_frames: Path | None = None keep_frames: Path | None = None
if _ask(questionary.confirm("保留中间 PNG 帧?", default=False)): if _ask(questionary.confirm("保留中间 PNG 帧?", default=False)):
@@ -300,8 +358,8 @@ def run_interactive_menu() -> int:
_ask(questionary.path("帧输出目录:", default="frames")) _ask(questionary.path("帧输出目录:", default="frames"))
).expanduser() ).expanduser()
cfg = ConversionConfig( template = ConversionConfig(
gif=gif, gif=gifs[0],
lvgl_dir=Path(lvgl_dir).expanduser(), lvgl_dir=Path(lvgl_dir).expanduser(),
output_dir=Path(output_dir).expanduser(), output_dir=Path(output_dir).expanduser(),
color_format=color_format, color_format=color_format,
@@ -311,13 +369,18 @@ def run_interactive_menu() -> int:
) )
print("\n--- 配置汇总 ---") print("\n--- 配置汇总 ---")
print(f" 源 GIF : {cfg.gif}") if single:
print(f" LVGL 目录 : {cfg.lvgl_dir}") print(f" 源 GIF : {gifs[0]}")
print(f" 输出目录 : {cfg.output_dir}") else:
print(f" 颜色格式 : {cfg.color_format}") print(f" 源 GIF : {len(gifs)}")
print(f" 压缩方式 : {cfg.compress}") for gif in gifs:
print(f" 帧前缀 : {cfg.prefix or cfg.gif.stem}") print(f" - {gif.name}")
print(f" 保留帧 : {cfg.keep_frames or '否(使用临时目录)'}") 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("----------------") print("----------------")
if not _ask(questionary.confirm("确认开始转换?", default=True)): if not _ask(questionary.confirm("确认开始转换?", default=True)):
@@ -326,7 +389,7 @@ def run_interactive_menu() -> int:
print("已取消。") print("已取消。")
return 0 return 0
return run_conversion(cfg) return run_conversions(gifs, template)
def main() -> int: def main() -> int:

View File

@@ -19,7 +19,7 @@ python3 -m pip install -r requirements.txt
## 交互式菜单 ## 交互式菜单
不带任何参数运行,会进入交互式菜单:可以从当前目录选择源 GIF、逐项配置参数最后确认并执行转换 不带任何参数运行,会进入交互式菜单:从当前目录选择源 GIF(支持**多选或一键全选**,批量转换)、逐项配置参数,最后确认并执行转换:
```bash ```bash
python3 EmbeddedAnimPacker.py python3 EmbeddedAnimPacker.py