Files
meme-api/转化.py
shumengya d452126069 feat(meme): 批量更新表情包素材与脚本
- 新增多个表情包分类与素材

- 更新转化.py、_redirects、memes.json 等配置

- 同步原图/ 与 public/meme/ 的本地处理结果
2026-06-06 16:59:09 +08:00

653 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
一键:从 原图/ 读取素材 → 输出到 原图-已处理/(默认)。
- 未编号、格式不符的:转为编号 .webp / .gif并按最长边缩放默认 250px
- 已符合 N.webp / N.gif 的:复制到 原图-已处理/ 后同样按最长边缩放(默认 250px
默认保留 原图 中源文件;需清理可加 --delete-sources。依赖 Pillow。
"""
from __future__ import annotations
import argparse
import io
import re
import sys
import tempfile
import threading
import zipfile
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Callable
try:
from PIL import Image, ImageSequence
except ImportError as exc: # pragma: no cover
raise SystemExit(
"需要 Pillowpython -m pip install pillow"
) from exc
IMAGE_EXTENSIONS = {
".avif",
".bmp",
".gif",
".jfif",
".jpeg",
".jpg",
".jpe",
".png",
".tif",
".tiff",
".webp",
}
OUTPUT_NAME_RE = re.compile(r"^(\d+)\.(webp|gif)$", re.IGNORECASE)
DEFAULT_SOURCE_DIR = Path("原图")
DEFAULT_OUTPUT_DIR = Path("原图-已处理")
DEFAULT_MAX_SIDE = 250
DEFAULT_JOBS = 10
_print_lock = threading.Lock()
def _log(*args, file=sys.stdout, **kwargs) -> None:
with _print_lock:
print(*args, file=file, **kwargs)
@dataclass(frozen=True)
class Job:
source_label: str
target_dir: Path
index: int
loader: Callable[[], bytes]
source_file: Path | None = None
# 若设置:直接写入 target_dir / dest_name已编号成品复制不转码、不缩放
dest_name: str | None = None
def is_image_path(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
def is_numbered_asset_file(path: Path) -> bool:
return path.is_file() and bool(OUTPUT_NAME_RE.match(path.name))
def max_index_in_directory(directory: Path) -> int:
highest = 0
if not directory.is_dir():
return 0
for p in directory.iterdir():
if not p.is_file():
continue
m = OUTPUT_NAME_RE.match(p.name)
if m:
highest = max(highest, int(m.group(1)))
return highest
def encode_to_webp_bytes(data: bytes) -> tuple[bytes, bool]:
with Image.open(io.BytesIO(data)) as im:
is_animated = bool(
getattr(im, "is_animated", False) or getattr(im, "n_frames", 1) > 1
)
if is_animated:
frames = []
durations = []
loop = im.info.get("loop", 0)
for frame in ImageSequence.Iterator(im):
frames.append(frame.convert("RGBA"))
durations.append(
frame.info.get("duration", im.info.get("duration", 100))
)
output = io.BytesIO()
frames[0].save(
output,
format="WEBP",
save_all=True,
append_images=frames[1:],
duration=durations,
loop=loop,
lossless=True,
method=4,
)
return output.getvalue(), True
output = io.BytesIO()
im.convert("RGBA").save(output, format="WEBP", lossless=True, method=4)
return output.getvalue(), False
def is_animated_gif(data: bytes) -> bool:
with Image.open(io.BytesIO(data)) as im:
if im.format != "GIF":
return False
n_frames = getattr(im, "n_frames", 1)
return bool(getattr(im, "is_animated", False) or n_frames > 1)
def is_animated_webp_bytes(data: bytes) -> bool:
with Image.open(io.BytesIO(data)) as im:
if im.format != "WEBP":
return False
return bool(
getattr(im, "is_animated", False) or getattr(im, "n_frames", 1) > 1
)
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 _read_zip_member(zip_path: Path, member_name: str) -> bytes:
with zipfile.ZipFile(zip_path) as zf:
return zf.read(member_name)
def collect_directory_jobs(
source_root: Path, output_root: Path, *, delete_sources: bool
) -> list[Job]:
grouped: dict[Path, list[Path]] = {}
for path in sorted(source_root.rglob("*")):
if not is_image_path(path):
continue
grouped.setdefault(path.parent, []).append(path)
jobs: list[Job] = []
for directory in sorted(grouped, key=lambda p: str(p)):
files = sorted(grouped[directory], key=lambda p: p.name)
target_dir = output_root / directory.relative_to(source_root)
conforming = [p for p in files if is_numbered_asset_file(p)]
non_conforming = [p for p in files if not is_numbered_asset_file(p)]
max_src = 0
for p in conforming:
m = OUTPUT_NAME_RE.match(p.name)
if m:
max_src = max(max_src, int(m.group(1)))
max_t = max_index_in_directory(target_dir)
next_i = max(max_src, max_t) + 1
for path in conforming:
jobs.append(
Job(
source_label=path.relative_to(source_root).as_posix(),
target_dir=target_dir,
index=0,
loader=lambda p=path: p.read_bytes(),
source_file=path if delete_sources else None,
dest_name=path.name,
)
)
for path in non_conforming:
jobs.append(
Job(
source_label=path.relative_to(source_root).as_posix(),
target_dir=target_dir,
index=next_i,
loader=lambda p=path: p.read_bytes(),
source_file=path if delete_sources else None,
dest_name=None,
)
)
next_i += 1
return jobs
def collect_zip_jobs(zip_path: Path, output_root: Path) -> list[Job]:
grouped: dict[PurePosixPath, list[PurePosixPath]] = {}
with zipfile.ZipFile(zip_path) as zf:
for info in zf.infolist():
if info.is_dir():
continue
member_path = PurePosixPath(info.filename)
suf = member_path.suffix.lower()
if suf not in IMAGE_EXTENSIONS:
continue
if OUTPUT_NAME_RE.match(member_path.name):
continue
grouped.setdefault(member_path.parent, []).append(member_path)
jobs: list[Job] = []
for directory in sorted(grouped, key=lambda p: str(p)):
files = sorted(grouped[directory], key=lambda p: str(p.name))
target_dir = output_root / Path(str(directory))
next_i = max_index_in_directory(target_dir) + 1
for member_path in files:
jobs.append(
Job(
source_label=member_path.as_posix(),
target_dir=target_dir,
index=next_i,
loader=lambda name=member_path.as_posix(), zp=zip_path: _read_zip_member(
zp, name
),
source_file=None,
)
)
next_i += 1
return jobs
def _remove_source_if_any(job: Job, out: Path, kind: str) -> str:
if job.source_file is None:
return kind
try:
src = job.source_file.resolve()
if src == out.resolve():
return kind
src.unlink()
return f"{kind}; removed-source"
except OSError as exc:
_log(
f"[warn] ok: {out} ({kind}) but could not delete {job.source_file}: {exc}",
file=sys.stderr,
)
return kind
# --- 最长边缩放(多帧仅 GIF/WebP---
def _is_multi_frame(im: Image.Image) -> bool:
return bool(
getattr(im, "is_animated", False) or getattr(im, "n_frames", 1) > 1
)
def _target_size(w: int, h: int, max_side: int) -> tuple[int, int] | None:
if max(w, h) <= max_side:
return None
if w >= h:
nw = max_side
nh = max(round(h * max_side / w), 1)
else:
nh = max_side
nw = max(round(w * max_side / h), 1)
return nw, nh
def _resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image:
return im.convert("RGBA").resize(size, Image.Resampling.LANCZOS)
def _static_save_format(path: Path, im: Image.Image) -> tuple[str, dict]:
ext = path.suffix.lower()
fmt = (im.format or "").upper()
if ext in (".jpg", ".jpeg", ".jpe") or fmt in ("JPEG", "MPO"):
return "JPEG", {"quality": 90, "optimize": True}
if ext == ".png" or fmt == "PNG":
return "PNG", {"optimize": True}
if ext == ".webp" or fmt == "WEBP":
return "WEBP", {"quality": 85, "method": 6}
if ext == ".gif" or fmt == "GIF":
return "GIF", {}
if ext in (".bmp",) or fmt == "BMP":
return "BMP", {}
if ext in (".tif", ".tiff") or fmt == "TIFF":
return "TIFF", {"compression": "tiff_lzw"}
if ext == ".avif" or fmt == "AVIF":
return "AVIF", {"quality": 80}
if ext == ".webp":
return "WEBP", {"quality": 85, "method": 6}
return "PNG", {"optimize": True}
def _atomic_save(path: Path, saver) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
suffix=path.suffix, delete=False, dir=path.parent
) as tmp:
tmp_path = Path(tmp.name)
try:
saver(tmp_path)
tmp_path.replace(path)
finally:
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
def _resize_static(im: Image.Image, path: Path, max_side: int) -> bool:
w, h = im.size
th = _target_size(w, h, max_side)
if th is None:
return False
out = im.resize(th, Image.Resampling.LANCZOS)
fmt, kwargs = _static_save_format(path, im)
if fmt == "JPEG" and out.mode in ("RGBA", "P"):
out = out.convert("RGB")
elif fmt == "JPEG" and out.mode not in ("RGB", "L"):
out = out.convert("RGB")
_atomic_save(path, lambda p: out.save(p, format=fmt, **kwargs))
return True
def _resize_animated(im: Image.Image, path: Path, max_side: int) -> bool:
w, h = im.size
th = _target_size(w, h, max_side)
if th is None:
return False
loop = int(im.info.get("loop", 0) or 0)
frames: list[Image.Image] = []
durations: list[int] = []
src_format = (im.format or "").upper()
for frame in ImageSequence.Iterator(im):
rgba = _resize_rgba(frame, th)
frames.append(rgba)
d = frame.info.get("duration", im.info.get("duration", 100))
try:
durations.append(int(d) if d is not None else 100)
except (TypeError, ValueError):
durations.append(100)
if len(frames) == 0:
return False
if len(durations) < len(frames):
durations.extend([durations[-1]] * (len(frames) - len(durations)))
def save_gif(p: Path) -> None:
if len(frames) == 1:
frames[0].save(p, format="GIF", duration=durations[0], loop=loop)
else:
frames[0].save(
p,
format="GIF",
save_all=True,
append_images=frames[1:],
duration=durations,
loop=loop,
disposal=2,
)
def save_webp(p: Path) -> None:
frames[0].save(
p,
format="WEBP",
save_all=True,
append_images=frames[1:],
duration=durations,
loop=loop,
quality=85,
method=6,
)
if src_format == "GIF" or path.suffix.lower() == ".gif":
_atomic_save(path, save_gif)
else:
_atomic_save(path, save_webp)
return True
def resize_output_if_needed(path: Path, max_side: int) -> None:
if max_side < 1:
return
try:
with Image.open(path) as im:
im.load()
if _is_multi_frame(im):
fmt = (im.format or "").upper()
ext = path.suffix.lower()
if fmt not in ("GIF", "WEBP") and ext not in (".gif", ".webp"):
_log(
f"skip resize animated (only GIF/WebP): {path}",
file=sys.stderr,
)
return
changed = _resize_animated(im, path, max_side)
else:
changed = _resize_static(im, path, max_side)
if changed:
_log(f"resized {path} (max side {max_side})")
except OSError as e:
_log(f"skip resize {path}: {e}", file=sys.stderr)
def run_job(job: Job, max_side: int) -> tuple[bool, str, Path, str | None, str | None]:
try:
data = job.loader()
job.target_dir.mkdir(parents=True, exist_ok=True)
if job.dest_name is not None:
out = job.target_dir / job.dest_name
out.write_bytes(data)
kind = _remove_source_if_any(job, out, "copy")
resize_output_if_needed(out, max_side)
return True, job.source_label, out, kind, None
if is_animated_gif(data):
out = job.target_dir / f"{job.index}.gif"
out.write_bytes(data)
kind = _remove_source_if_any(job, out, "gif-animated")
resize_output_if_needed(out, max_side)
return True, job.source_label, out, kind, None
if is_animated_webp_bytes(data):
out = job.target_dir / f"{job.index}.gif"
webp_bytes_to_gif(data, out)
kind = _remove_source_if_any(job, out, "animated-webp->gif")
resize_output_if_needed(out, max_side)
return True, job.source_label, out, kind, None
webp_data, animated = encode_to_webp_bytes(data)
out = job.target_dir / f"{job.index}.webp"
out.write_bytes(webp_data)
base = "animated-webp" if animated else "static-webp"
kind = _remove_source_if_any(job, out, base)
resize_output_if_needed(out, max_side)
return True, job.source_label, out, kind, None
except Exception as exc:
return False, job.source_label, job.target_dir / f"{job.index}.?", None, str(exc)
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description=(
"原图/ 下:未编号素材转为编号 .webp/.gif 并限最长边;"
"已符合 N.webp/N.gif 的复制到 原图-已处理/ 后同样限最长边。"
"默认读取 原图/、保留源文件。"
)
)
parser.add_argument(
"source",
nargs="?",
default=None,
type=Path,
help=f"源:目录、单图或 .zip默认: {DEFAULT_SOURCE_DIR.as_posix()}/",
)
parser.add_argument(
"-o",
"--output",
default=None,
dest="output_root",
type=Path,
help=f"输出根目录(默认: 目录模式为 {DEFAULT_OUTPUT_DIR.as_posix()}/"
"zip 为 zip 同目录下以压缩包名为文件夹;单图为该目录)",
)
parser.add_argument(
"--max",
type=int,
default=DEFAULT_MAX_SIDE,
metavar="PX",
help=f"最长边像素上限,超过则缩小(默认 {DEFAULT_MAX_SIDE}0 表示不缩放)",
)
parser.add_argument(
"-j",
"--jobs",
type=int,
default=DEFAULT_JOBS,
help=f"线程数(默认 {DEFAULT_JOBS}",
)
parser.add_argument(
"--delete-sources",
action="store_true",
help="成功后删除 原图 中已处理的源文件(含已编号复制项;默认保留)",
)
args = parser.parse_args(argv)
cwd = Path.cwd()
source = (
args.source.expanduser().resolve()
if args.source is not None
else (cwd / DEFAULT_SOURCE_DIR).resolve()
)
jobs: list[Job] = []
max_side: int = args.max
if source.is_dir():
out_root = (
args.output_root.expanduser().resolve()
if args.output_root is not None
else (cwd / DEFAULT_OUTPUT_DIR).resolve()
)
out_root.mkdir(parents=True, exist_ok=True)
jobs = collect_directory_jobs(
source, out_root, delete_sources=args.delete_sources
)
elif source.is_file() and source.suffix.lower() == ".zip":
out_root = (
args.output_root.expanduser().resolve()
if args.output_root is not None
else (source.parent / source.stem).resolve()
)
out_root.mkdir(parents=True, exist_ok=True)
jobs = collect_zip_jobs(source, out_root)
elif is_image_path(source):
target_dir = (
args.output_root.expanduser().resolve()
if args.output_root is not None
else (cwd / DEFAULT_OUTPUT_DIR).resolve()
)
target_dir.mkdir(parents=True, exist_ok=True)
if is_numbered_asset_file(source):
jobs = [
Job(
source_label=source.name,
target_dir=target_dir,
index=0,
loader=lambda p=source: p.read_bytes(),
source_file=source if args.delete_sources else None,
dest_name=source.name,
)
]
else:
idx = max_index_in_directory(target_dir) + 1
jobs = [
Job(
source_label=source.name,
target_dir=target_dir,
index=idx,
loader=lambda p=source: p.read_bytes(),
source_file=source if args.delete_sources else None,
)
]
else:
print(f"不是目录、图片或 zip: {source}", file=sys.stderr)
return 1
jobs.sort(
key=lambda job: (
str(job.target_dir),
(job.dest_name or "").lower(),
f"{job.index:08d}",
)
)
if not jobs:
print(f"没有待处理文件: {source}")
return 0
converted = 0
copied = 0
skipped = 0
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
for ok, label, target, kind, error in pool.map(
lambda j: run_job(j, max_side), jobs
):
if ok:
print(f"{label} -> {target} ({kind})")
base_kind = (kind or "").split(";")[0].strip()
if base_kind == "copy":
copied += 1
else:
converted += 1
else:
print(f"[skip] {label}: {error}", file=sys.stderr)
skipped += 1
print(f"完成:转换={converted}, 复制={copied}, 失败={skipped}")
return 0 if (converted + copied) > 0 else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))