Migrate to Pages: public/meme assets, gallery and scripts; remove root meme/; tooling defaults
Made-with: Cursor
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:]))
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import io
|
import io
|
||||||
import os
|
import re
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
@@ -18,6 +18,7 @@ except ImportError as exc: # pragma: no cover
|
|||||||
"This script requires Pillow. Install it with: python3 -m pip install pillow"
|
"This script requires Pillow. Install it with: python3 -m pip install pillow"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
from animated_webp_to_gif import webp_bytes_to_gif
|
||||||
|
|
||||||
IMAGE_EXTENSIONS = {
|
IMAGE_EXTENSIONS = {
|
||||||
".avif",
|
".avif",
|
||||||
@@ -30,20 +31,46 @@ IMAGE_EXTENSIONS = {
|
|||||||
".png",
|
".png",
|
||||||
".tif",
|
".tif",
|
||||||
".tiff",
|
".tiff",
|
||||||
|
".webp",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 已编号的成品:1.webp、42.gif(不参与转换;用于各子文件夹内续号)
|
||||||
|
OUTPUT_NAME_RE = re.compile(r"^(\d+)\.(webp|gif)$", re.IGNORECASE)
|
||||||
|
|
||||||
|
DEFAULT_SOURCE_DIR = Path("原图")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Job:
|
class Job:
|
||||||
source_label: str
|
source_label: str
|
||||||
target_path: Path
|
target_dir: Path
|
||||||
|
index: int
|
||||||
loader: Callable[[], bytes]
|
loader: Callable[[], bytes]
|
||||||
|
# 磁盘上的源文件;转换成功后删除(zip 成员为 None)。与输出路径相同时不会删。
|
||||||
|
source_file: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
def is_image_path(path: Path) -> bool:
|
def is_image_path(path: Path) -> bool:
|
||||||
return path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
|
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 convert_to_webp(data: bytes) -> tuple[bytes, bool]:
|
def convert_to_webp(data: bytes) -> tuple[bytes, bool]:
|
||||||
with Image.open(io.BytesIO(data)) as im:
|
with Image.open(io.BytesIO(data)) as im:
|
||||||
is_animated = bool(
|
is_animated = bool(
|
||||||
@@ -76,137 +103,237 @@ def convert_to_webp(data: bytes) -> tuple[bytes, bool]:
|
|||||||
return output.getvalue(), False
|
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 _read_zip_member(zip_path: Path, member_name: str) -> bytes:
|
def _read_zip_member(zip_path: Path, member_name: str) -> bytes:
|
||||||
with zipfile.ZipFile(zip_path) as zf:
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
return zf.read(member_name)
|
return zf.read(member_name)
|
||||||
|
|
||||||
|
|
||||||
def collect_directory_jobs(root: Path, output_base: Path) -> list[Job]:
|
def collect_directory_jobs(
|
||||||
|
source_root: Path, output_root: Path, *, delete_sources: bool
|
||||||
|
) -> list[Job]:
|
||||||
grouped: dict[Path, list[Path]] = {}
|
grouped: dict[Path, list[Path]] = {}
|
||||||
for path in sorted(root.rglob("*")):
|
for path in sorted(source_root.rglob("*")):
|
||||||
if not is_image_path(path):
|
if not is_image_path(path):
|
||||||
continue
|
continue
|
||||||
|
if is_numbered_asset_file(path):
|
||||||
|
continue
|
||||||
grouped.setdefault(path.parent, []).append(path)
|
grouped.setdefault(path.parent, []).append(path)
|
||||||
|
|
||||||
jobs: list[Job] = []
|
jobs: list[Job] = []
|
||||||
for directory in sorted(grouped):
|
for directory in sorted(grouped, key=lambda p: str(p)):
|
||||||
files = sorted(grouped[directory], key=lambda p: p.name)
|
files = sorted(grouped[directory], key=lambda p: p.name)
|
||||||
relative_dir = directory.relative_to(root)
|
target_dir = output_root / directory.relative_to(source_root)
|
||||||
target_dir = output_base / relative_dir
|
next_i = max_index_in_directory(target_dir) + 1
|
||||||
for index, path in enumerate(files, start=1):
|
for path in files:
|
||||||
jobs.append(
|
jobs.append(
|
||||||
Job(
|
Job(
|
||||||
source_label=path.relative_to(root).as_posix(),
|
source_label=path.relative_to(source_root).as_posix(),
|
||||||
target_path=target_dir / f"{index}.webp",
|
target_dir=target_dir,
|
||||||
|
index=next_i,
|
||||||
loader=lambda p=path: p.read_bytes(),
|
loader=lambda p=path: p.read_bytes(),
|
||||||
|
source_file=path if delete_sources else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
next_i += 1
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
def collect_zip_jobs(zip_path: Path, output_base: Path) -> list[Job]:
|
def collect_zip_jobs(zip_path: Path, output_root: Path) -> list[Job]:
|
||||||
grouped: dict[PurePosixPath, list[PurePosixPath]] = {}
|
grouped: dict[PurePosixPath, list[PurePosixPath]] = {}
|
||||||
with zipfile.ZipFile(zip_path) as zf:
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
for info in zf.infolist():
|
for info in zf.infolist():
|
||||||
if info.is_dir():
|
if info.is_dir():
|
||||||
continue
|
continue
|
||||||
member_path = PurePosixPath(info.filename)
|
member_path = PurePosixPath(info.filename)
|
||||||
if member_path.suffix.lower() not in IMAGE_EXTENSIONS:
|
suf = member_path.suffix.lower()
|
||||||
|
if suf not in IMAGE_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
if OUTPUT_NAME_RE.match(member_path.name):
|
||||||
continue
|
continue
|
||||||
grouped.setdefault(member_path.parent, []).append(member_path)
|
grouped.setdefault(member_path.parent, []).append(member_path)
|
||||||
|
|
||||||
jobs: list[Job] = []
|
jobs: list[Job] = []
|
||||||
for directory in sorted(grouped, key=lambda p: str(p)):
|
for directory in sorted(grouped, key=lambda p: str(p)):
|
||||||
files = sorted(grouped[directory], key=lambda p: str(p.name))
|
files = sorted(grouped[directory], key=lambda p: str(p.name))
|
||||||
for index, member_path in enumerate(files, start=1):
|
target_dir = output_root / Path(str(directory))
|
||||||
target_dir = output_base / Path(str(directory))
|
next_i = max_index_in_directory(target_dir) + 1
|
||||||
|
for member_path in files:
|
||||||
jobs.append(
|
jobs.append(
|
||||||
Job(
|
Job(
|
||||||
source_label=member_path.as_posix(),
|
source_label=member_path.as_posix(),
|
||||||
target_path=target_dir / f"{index}.webp",
|
target_dir=target_dir,
|
||||||
|
index=next_i,
|
||||||
loader=lambda name=member_path.as_posix(), zp=zip_path: _read_zip_member(
|
loader=lambda name=member_path.as_posix(), zp=zip_path: _read_zip_member(
|
||||||
zp, name
|
zp, name
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
next_i += 1
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
def collect_jobs(source: Path, output_base: Path | None) -> list[Job]:
|
def _remove_source_if_any(job: Job, out: Path, kind: str) -> str:
|
||||||
if source.is_dir():
|
if job.source_file is None:
|
||||||
base = output_base if output_base is not None else source.parent / f"{source.name}-out"
|
return kind
|
||||||
base.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
return collect_directory_jobs(source, base)
|
src = job.source_file.resolve()
|
||||||
|
if src == out.resolve():
|
||||||
if source.is_file() and source.suffix.lower() == ".zip":
|
return kind
|
||||||
base = output_base if output_base is not None else source.parent / source.stem
|
src.unlink()
|
||||||
base.mkdir(parents=True, exist_ok=True)
|
return f"{kind}; removed-source"
|
||||||
return collect_zip_jobs(source, base)
|
except OSError as exc:
|
||||||
|
print(
|
||||||
if is_image_path(source):
|
f"[warn] ok: {out} ({kind}) but could not delete {job.source_file}: {exc}",
|
||||||
return [
|
file=sys.stderr,
|
||||||
Job(
|
)
|
||||||
source_label=source.name,
|
return kind
|
||||||
target_path=source.parent / "1.webp",
|
|
||||||
loader=lambda p=source: p.read_bytes(),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def run_job(job: Job) -> tuple[bool, str, Path, bool | None, str | None]:
|
def run_job(job: Job) -> tuple[bool, str, Path, str | None, str | None]:
|
||||||
try:
|
try:
|
||||||
data = job.loader()
|
data = job.loader()
|
||||||
|
job.target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
return True, job.source_label, out, kind, None
|
||||||
|
|
||||||
webp_data, animated = convert_to_webp(data)
|
webp_data, animated = convert_to_webp(data)
|
||||||
job.target_path.parent.mkdir(parents=True, exist_ok=True)
|
out = job.target_dir / f"{job.index}.webp"
|
||||||
job.target_path.write_bytes(webp_data)
|
out.write_bytes(webp_data)
|
||||||
return True, job.source_label, job.target_path, animated, None
|
base = "animated-webp" if animated else "static-webp"
|
||||||
|
kind = _remove_source_if_any(job, out, base)
|
||||||
|
return True, job.source_label, out, kind, None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return False, job.source_label, job.target_path, None, str(exc)
|
return False, job.source_label, job.target_dir / f"{job.index}.?", None, str(exc)
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
def main(argv: list[str]) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Convert images to WebP in place, with independent numbering per folder."
|
description=(
|
||||||
|
"Read unnumbered images from 原图/ (by default), write numbered *.webp / *.gif "
|
||||||
|
"into the same folder tree (in-place). Subfolders mirror 原图. "
|
||||||
|
"Animated GIFs and animated WebPs become .gif; static images become lossless .webp. "
|
||||||
|
"Next index per folder = max existing N in that folder + 1. "
|
||||||
|
"By default unnumbered source files are deleted after a successful conversion."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"source",
|
"source",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default=".",
|
default=None,
|
||||||
help="Source folder, image file, or .zip archive. Default: current directory.",
|
type=Path,
|
||||||
|
help=f"Source root folder, single image, or .zip (default: {DEFAULT_SOURCE_DIR.as_posix()}/).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output-base",
|
"-o",
|
||||||
|
"--output",
|
||||||
default=None,
|
default=None,
|
||||||
help="Output root. Default for folders: <source>-out next to the source. Default for zip: a folder next to the zip with the same name.",
|
dest="output_root",
|
||||||
|
type=Path,
|
||||||
|
help="Output root (default: same as source for folders/single file; for .zip default: <zip-stem>/).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-j",
|
"-j",
|
||||||
"--jobs",
|
"--jobs",
|
||||||
type=int,
|
type=int,
|
||||||
default=max(1, min(32, os.cpu_count() or 4)),
|
default=10,
|
||||||
help="Number of worker threads. Default: number of CPU cores.",
|
help="Number of worker threads (default: 10).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-sources",
|
||||||
|
action="store_true",
|
||||||
|
help="Keep unnumbered source files after conversion (default: delete them).",
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
source = Path(args.source).expanduser().resolve()
|
cwd = Path.cwd()
|
||||||
output_base = Path(args.output_base).expanduser().resolve() if args.output_base else None
|
source = (
|
||||||
|
args.source.expanduser().resolve()
|
||||||
|
if args.source is not None
|
||||||
|
else (cwd / DEFAULT_SOURCE_DIR).resolve()
|
||||||
|
)
|
||||||
|
jobs: list[Job] = []
|
||||||
|
|
||||||
jobs = collect_jobs(source, output_base)
|
if source.is_dir():
|
||||||
jobs.sort(key=lambda job: str(job.target_path))
|
out_root = (
|
||||||
|
args.output_root.expanduser().resolve()
|
||||||
|
if args.output_root is not None
|
||||||
|
else source
|
||||||
|
)
|
||||||
|
out_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
jobs = collect_directory_jobs(
|
||||||
|
source, out_root, delete_sources=not args.keep_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):
|
||||||
|
if is_numbered_asset_file(source):
|
||||||
|
print(f"Skip already-numbered file: {source}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
target_dir = (
|
||||||
|
args.output_root.expanduser().resolve()
|
||||||
|
if args.output_root is not None
|
||||||
|
else source.parent.resolve()
|
||||||
|
)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
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 not args.keep_sources else None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
print(f"Not a folder, image, or zip: {source}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
jobs.sort(key=lambda job: (str(job.target_dir), job.index))
|
||||||
|
|
||||||
if not jobs:
|
if not jobs:
|
||||||
print(f"No supported images found in: {source}")
|
print(f"No new images to convert under: {source}")
|
||||||
return 1
|
return 0
|
||||||
|
|
||||||
converted = 0
|
converted = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
||||||
for ok, label, target, animated, error in pool.map(run_job, jobs):
|
for ok, label, target, kind, error in pool.map(run_job, jobs):
|
||||||
if ok:
|
if ok:
|
||||||
kind = "animated" if animated else "static"
|
|
||||||
print(f"{label} -> {target} ({kind})")
|
print(f"{label} -> {target} ({kind})")
|
||||||
converted += 1
|
converted += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 452 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 450 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 562 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 507 KiB |
|
Before Width: | Height: | Size: 17 KiB |
BIN
meme/momo/1.webp
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 324 KiB |