Initial commit
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
221
convert_to_webp.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
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(
|
||||||
|
"This script requires Pillow. Install it with: python3 -m pip install pillow"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = {
|
||||||
|
".avif",
|
||||||
|
".bmp",
|
||||||
|
".gif",
|
||||||
|
".jfif",
|
||||||
|
".jpeg",
|
||||||
|
".jpg",
|
||||||
|
".jpe",
|
||||||
|
".png",
|
||||||
|
".tif",
|
||||||
|
".tiff",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Job:
|
||||||
|
source_label: str
|
||||||
|
target_path: Path
|
||||||
|
loader: Callable[[], bytes]
|
||||||
|
|
||||||
|
|
||||||
|
def is_image_path(path: Path) -> bool:
|
||||||
|
return path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_webp(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 _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(root: Path, output_base: Path) -> list[Job]:
|
||||||
|
grouped: dict[Path, list[Path]] = {}
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if not is_image_path(path):
|
||||||
|
continue
|
||||||
|
grouped.setdefault(path.parent, []).append(path)
|
||||||
|
|
||||||
|
jobs: list[Job] = []
|
||||||
|
for directory in sorted(grouped):
|
||||||
|
files = sorted(grouped[directory], key=lambda p: p.name)
|
||||||
|
relative_dir = directory.relative_to(root)
|
||||||
|
target_dir = output_base / relative_dir
|
||||||
|
for index, path in enumerate(files, start=1):
|
||||||
|
jobs.append(
|
||||||
|
Job(
|
||||||
|
source_label=path.relative_to(root).as_posix(),
|
||||||
|
target_path=target_dir / f"{index}.webp",
|
||||||
|
loader=lambda p=path: p.read_bytes(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def collect_zip_jobs(zip_path: Path, output_base: 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)
|
||||||
|
if member_path.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||||
|
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))
|
||||||
|
for index, member_path in enumerate(files, start=1):
|
||||||
|
target_dir = output_base / Path(str(directory))
|
||||||
|
jobs.append(
|
||||||
|
Job(
|
||||||
|
source_label=member_path.as_posix(),
|
||||||
|
target_path=target_dir / f"{index}.webp",
|
||||||
|
loader=lambda name=member_path.as_posix(), zp=zip_path: _read_zip_member(
|
||||||
|
zp, name
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def collect_jobs(source: Path, output_base: Path | None) -> list[Job]:
|
||||||
|
if source.is_dir():
|
||||||
|
base = output_base if output_base is not None else source.parent / f"{source.name}-out"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
return collect_directory_jobs(source, base)
|
||||||
|
|
||||||
|
if source.is_file() and source.suffix.lower() == ".zip":
|
||||||
|
base = output_base if output_base is not None else source.parent / source.stem
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
return collect_zip_jobs(source, base)
|
||||||
|
|
||||||
|
if is_image_path(source):
|
||||||
|
return [
|
||||||
|
Job(
|
||||||
|
source_label=source.name,
|
||||||
|
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]:
|
||||||
|
try:
|
||||||
|
data = job.loader()
|
||||||
|
webp_data, animated = convert_to_webp(data)
|
||||||
|
job.target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
job.target_path.write_bytes(webp_data)
|
||||||
|
return True, job.source_label, job.target_path, animated, None
|
||||||
|
except Exception as exc:
|
||||||
|
return False, job.source_label, job.target_path, None, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Convert images to WebP in place, with independent numbering per folder."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"source",
|
||||||
|
nargs="?",
|
||||||
|
default=".",
|
||||||
|
help="Source folder, image file, or .zip archive. Default: current directory.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-base",
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-j",
|
||||||
|
"--jobs",
|
||||||
|
type=int,
|
||||||
|
default=max(1, min(32, os.cpu_count() or 4)),
|
||||||
|
help="Number of worker threads. Default: number of CPU cores.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
source = Path(args.source).expanduser().resolve()
|
||||||
|
output_base = Path(args.output_base).expanduser().resolve() if args.output_base else None
|
||||||
|
|
||||||
|
jobs = collect_jobs(source, output_base)
|
||||||
|
jobs.sort(key=lambda job: str(job.target_path))
|
||||||
|
|
||||||
|
if not jobs:
|
||||||
|
print(f"No supported images found in: {source}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
converted = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
||||||
|
for ok, label, target, animated, error in pool.map(run_job, jobs):
|
||||||
|
if ok:
|
||||||
|
kind = "animated" if animated else "static"
|
||||||
|
print(f"{label} -> {target} ({kind})")
|
||||||
|
converted += 1
|
||||||
|
else:
|
||||||
|
print(f"[skip] {label}: {error}", file=sys.stderr)
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
print(f"Done. converted={converted}, skipped={skipped}")
|
||||||
|
return 0 if converted else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
BIN
meme/cheems/1.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
meme/deepseek/1.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
meme/deepseek/2.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
meme/emoji/1.webp
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
meme/emoji/10.webp
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
meme/emoji/11.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
meme/emoji/12.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
meme/emoji/13.webp
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
meme/emoji/14.webp
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
meme/emoji/15.webp
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
meme/emoji/16.webp
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
meme/emoji/17.webp
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
meme/emoji/18.webp
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
meme/emoji/19.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
meme/emoji/2.webp
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
meme/emoji/20.webp
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
meme/emoji/21.webp
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
meme/emoji/22.webp
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
meme/emoji/23.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
meme/emoji/24.webp
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
meme/emoji/25.webp
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
meme/emoji/26.webp
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
meme/emoji/27.webp
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
meme/emoji/28.webp
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
meme/emoji/29.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
meme/emoji/3.webp
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
meme/emoji/30.webp
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
meme/emoji/31.webp
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
meme/emoji/32.webp
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
meme/emoji/33.webp
Normal file
|
After Width: | Height: | Size: 456 KiB |
BIN
meme/emoji/34.webp
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
meme/emoji/35.webp
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
meme/emoji/36.webp
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
meme/emoji/37.webp
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
meme/emoji/38.webp
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
meme/emoji/39.webp
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
meme/emoji/4.webp
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
meme/emoji/40.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
meme/emoji/41.webp
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
meme/emoji/42.webp
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
meme/emoji/43.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
meme/emoji/44.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
meme/emoji/45.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
meme/emoji/46.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
meme/emoji/47.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
meme/emoji/48.webp
Normal file
|
After Width: | Height: | Size: 292 KiB |
BIN
meme/emoji/49.webp
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
meme/emoji/5.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
meme/emoji/50.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
meme/emoji/51.webp
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
meme/emoji/52.webp
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
meme/emoji/53.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
meme/emoji/54.webp
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
meme/emoji/55.webp
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
meme/emoji/56.webp
Normal file
|
After Width: | Height: | Size: 452 KiB |
BIN
meme/emoji/57.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
meme/emoji/58.webp
Normal file
|
After Width: | Height: | Size: 450 KiB |
BIN
meme/emoji/59.webp
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
meme/emoji/6.webp
Normal file
|
After Width: | Height: | Size: 389 KiB |
BIN
meme/emoji/60.webp
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
meme/emoji/61.webp
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
meme/emoji/62.webp
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
meme/emoji/63.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
meme/emoji/64.webp
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
meme/emoji/65.webp
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
meme/emoji/66.webp
Normal file
|
After Width: | Height: | Size: 367 KiB |
BIN
meme/emoji/67.webp
Normal file
|
After Width: | Height: | Size: 311 KiB |
BIN
meme/emoji/68.webp
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
meme/emoji/69.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
meme/emoji/7.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
meme/emoji/70.webp
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
meme/emoji/71.webp
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
meme/emoji/72.webp
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
meme/emoji/73.webp
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
meme/emoji/74.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
meme/emoji/75.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
meme/emoji/76.webp
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
meme/emoji/77.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
meme/emoji/78.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
meme/emoji/79.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
meme/emoji/8.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
meme/emoji/80.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
meme/emoji/81.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
meme/emoji/82.webp
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
meme/emoji/83.webp
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
meme/emoji/84.webp
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
meme/emoji/9.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
meme/happycat/1.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
meme/happycat/2.webp
Normal file
|
After Width: | Height: | Size: 507 KiB |
BIN
meme/happycat/3.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
meme/momo/1.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
meme/momo/10.webp
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
meme/momo/11.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
meme/momo/12.webp
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
meme/momo/13.webp
Normal file
|
After Width: | Height: | Size: 332 KiB |
BIN
meme/momo/14.webp
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
meme/momo/15.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
meme/momo/16.webp
Normal file
|
After Width: | Height: | Size: 198 KiB |