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`,提供两种用法:
- **命令行**:传入 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 sys
import tempfile
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
@@ -197,6 +197,34 @@ def run_conversion(cfg: ConversionConfig) -> int:
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
# --------------------------------------------------------------------------- #
@@ -219,31 +247,58 @@ def discover_gifs() -> list[Path]:
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 = "✏️ 手动输入路径…"
quit_choice = "退出"
while True:
by_name = {gif.name: gif for gif in discover_gifs()}
if not by_name:
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"
)
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]
all_choice = None
choices = [manual, quit_choice]
if candidate.is_file() and candidate.suffix.lower() == ".gif":
return candidate
questionary.print(f"无效的 GIF 文件:{candidate}", style="bold fg:red")
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:
@@ -261,7 +316,8 @@ def run_interactive_menu() -> int:
print("=== EmbeddedAnimPacker 交互式菜单 ===")
try:
gif = _select_gif(questionary)
gifs = _select_gifs(questionary)
single = len(gifs) == 1
lvgl_dir = _ask(
questionary.path("LVGL 目录:", default=str(DEFAULT_LVGL_DIR))
@@ -290,6 +346,8 @@ def run_interactive_menu() -> int:
)
)
prefix = None
if single:
prefix = _ask(
questionary.text("帧文件名前缀(留空使用 GIF 文件名):", default="")
).strip() or None
@@ -300,8 +358,8 @@ def run_interactive_menu() -> int:
_ask(questionary.path("帧输出目录:", default="frames"))
).expanduser()
cfg = ConversionConfig(
gif=gif,
template = ConversionConfig(
gif=gifs[0],
lvgl_dir=Path(lvgl_dir).expanduser(),
output_dir=Path(output_dir).expanduser(),
color_format=color_format,
@@ -311,13 +369,18 @@ def run_interactive_menu() -> int:
)
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 '否(使用临时目录)'}")
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)):
@@ -326,7 +389,7 @@ def run_interactive_menu() -> int:
print("已取消。")
return 0
return run_conversion(cfg)
return run_conversions(gifs, template)
def main() -> int:

View File

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