Migrate to Pages: public/meme assets, gallery and scripts; remove root meme/; tooling defaults
Made-with: Cursor
This commit is contained in:
212
animated_webp_to_gif.py
Normal file
212
animated_webp_to_gif.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recursively find *.webp under 原图/ (by default). Animated WebP -> same-stem .gif
|
||||
(then remove the .webp). Static WebP unchanged. Default 10 worker threads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageSequence
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"This script requires Pillow. Install it with: python3 -m pip install pillow"
|
||||
) from exc
|
||||
|
||||
|
||||
# 只处理已编号的成品动图(避免误处理未编号的素材)
|
||||
OUTPUT_NAME_RE = re.compile(r"^(\d+)\.webp$", re.IGNORECASE)
|
||||
|
||||
DEFAULT_JOBS = 10
|
||||
_log_lock = threading.Lock()
|
||||
|
||||
|
||||
def _log(*args, file=sys.stdout, **kwargs) -> None:
|
||||
with _log_lock:
|
||||
print(*args, file=file, **kwargs)
|
||||
|
||||
|
||||
def convert_one(path: Path, root: Path, dry_run: bool) -> str:
|
||||
try:
|
||||
if not is_animated_webp(path):
|
||||
return "skip_static"
|
||||
except OSError as e:
|
||||
_log(f"[skip] {path}: {e}", file=sys.stderr)
|
||||
return "error"
|
||||
gif_path = path.with_suffix(".gif")
|
||||
rel = path.relative_to(root)
|
||||
if dry_run:
|
||||
_log(f"would convert: {rel} -> {gif_path.name}")
|
||||
return "ok"
|
||||
try:
|
||||
webp_to_gif(path, gif_path)
|
||||
path.unlink()
|
||||
_log(f"{rel} -> {gif_path.name} (animated)")
|
||||
return "ok"
|
||||
except Exception as e:
|
||||
_log(f"[error] {path}: {e}", file=sys.stderr)
|
||||
if gif_path.exists():
|
||||
try:
|
||||
gif_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return "error"
|
||||
|
||||
|
||||
def is_animated_webp_image(im: Image.Image) -> bool:
|
||||
if im.format != "WEBP":
|
||||
return False
|
||||
return bool(
|
||||
getattr(im, "is_animated", False) or getattr(im, "n_frames", 1) > 1
|
||||
)
|
||||
|
||||
|
||||
def is_animated_webp(path: Path) -> bool:
|
||||
with Image.open(path) as im:
|
||||
return is_animated_webp_image(im)
|
||||
|
||||
|
||||
def save_animated_webp_image_to_gif(im: Image.Image, dst: Path) -> None:
|
||||
if im.format != "WEBP":
|
||||
raise ValueError("expected WebP image")
|
||||
loop = im.info.get("loop", 0)
|
||||
frames: list[Image.Image] = []
|
||||
durations: list[int] = []
|
||||
for frame in ImageSequence.Iterator(im):
|
||||
rgba = frame.convert("RGBA")
|
||||
frames.append(rgba)
|
||||
d = frame.info.get(
|
||||
"duration", im.info.get("duration", im.info.get("gif_duration", 100))
|
||||
)
|
||||
try:
|
||||
durations.append(int(d) if d is not None else 100)
|
||||
except (TypeError, ValueError):
|
||||
durations.append(100)
|
||||
|
||||
if len(frames) == 0:
|
||||
raise ValueError("no frames")
|
||||
if len(durations) < len(frames):
|
||||
durations.extend([durations[-1]] * (len(frames) - len(durations)))
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".gif", delete=False, dir=dst.parent
|
||||
) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
if len(frames) == 1:
|
||||
frames[0].save(
|
||||
tmp_path,
|
||||
format="GIF",
|
||||
duration=durations[0],
|
||||
loop=loop,
|
||||
)
|
||||
else:
|
||||
frames[0].save(
|
||||
tmp_path,
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=durations,
|
||||
loop=loop,
|
||||
disposal=2,
|
||||
)
|
||||
tmp_path.replace(dst)
|
||||
finally:
|
||||
if tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def webp_bytes_to_gif(data: bytes, dst: Path) -> None:
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
save_animated_webp_image_to_gif(im, dst)
|
||||
|
||||
|
||||
def webp_to_gif(src: Path, dst: Path) -> None:
|
||||
with Image.open(src) as im:
|
||||
save_animated_webp_image_to_gif(im, dst)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Convert animated WebP under 原图/ to GIF (in place); "
|
||||
"static WebP unchanged. Only numbered N.webp files are considered."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"root",
|
||||
nargs="?",
|
||||
default="原图",
|
||||
type=Path,
|
||||
help="Root directory to scan (default: 原图).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Only print what would be converted.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all-webp",
|
||||
action="store_true",
|
||||
help="Process any *.webp, not only numbered N.webp.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
default=DEFAULT_JOBS,
|
||||
metavar="N",
|
||||
help=f"Parallel worker threads (default: {DEFAULT_JOBS}).",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
root = args.root.expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
print(f"Not a directory: {root}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
webp_files = sorted(root.rglob("*.webp"))
|
||||
converted = 0
|
||||
skipped_static = 0
|
||||
skipped_name = 0
|
||||
errors = 0
|
||||
|
||||
candidates: list[Path] = []
|
||||
for path in webp_files:
|
||||
if not args.all_webp and not OUTPUT_NAME_RE.match(path.name):
|
||||
skipped_name += 1
|
||||
continue
|
||||
candidates.append(path)
|
||||
|
||||
workers = max(1, args.jobs)
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
for outcome in pool.map(
|
||||
lambda p: convert_one(p, root, args.dry_run), candidates
|
||||
):
|
||||
if outcome == "ok":
|
||||
converted += 1
|
||||
elif outcome == "skip_static":
|
||||
skipped_static += 1
|
||||
else:
|
||||
errors += 1
|
||||
|
||||
print(
|
||||
f"Done. converted={converted}, skipped_static_webp={skipped_static}, "
|
||||
f"skipped_unnumbered={skipped_name}, errors={errors} ({workers} workers)"
|
||||
)
|
||||
return 0 if errors == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user