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:
@@ -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 作帧前缀以避免冲突。
|
||||||
|
|
||||||
## 重要文件
|
## 重要文件
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user