- 新增多个表情包分类与素材 - 更新转化.py、_redirects、memes.json 等配置 - 同步原图/ 与 public/meme/ 的本地处理结果
653 lines
20 KiB
Python
653 lines
20 KiB
Python
#!/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(
|
||
"需要 Pillow:python -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:]))
|