添加新的表情包
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
.wrangler/
|
.wrangler/
|
||||||
.dev.vars
|
.dev.vars
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
#!/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:]))
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import io
|
|
||||||
import re
|
|
||||||
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
|
|
||||||
|
|
||||||
from animated_webp_to_gif import webp_bytes_to_gif
|
|
||||||
|
|
||||||
IMAGE_EXTENSIONS = {
|
|
||||||
".avif",
|
|
||||||
".bmp",
|
|
||||||
".gif",
|
|
||||||
".jfif",
|
|
||||||
".jpeg",
|
|
||||||
".jpg",
|
|
||||||
".jpe",
|
|
||||||
".png",
|
|
||||||
".tif",
|
|
||||||
".tiff",
|
|
||||||
".webp",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 已编号的成品:1.webp、42.gif(不参与转换;用于各子文件夹内续号)
|
|
||||||
OUTPUT_NAME_RE = re.compile(r"^(\d+)\.(webp|gif)$", re.IGNORECASE)
|
|
||||||
|
|
||||||
DEFAULT_SOURCE_DIR = Path("原图")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Job:
|
|
||||||
source_label: str
|
|
||||||
target_dir: Path
|
|
||||||
index: int
|
|
||||||
loader: Callable[[], bytes]
|
|
||||||
# 磁盘上的源文件;转换成功后删除(zip 成员为 None)。与输出路径相同时不会删。
|
|
||||||
source_file: Path | 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 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 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:
|
|
||||||
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
|
|
||||||
if is_numbered_asset_file(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)
|
|
||||||
next_i = max_index_in_directory(target_dir) + 1
|
|
||||||
for path in files:
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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:
|
|
||||||
print(
|
|
||||||
f"[warn] ok: {out} ({kind}) but could not delete {job.source_file}: {exc}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return kind
|
|
||||||
|
|
||||||
|
|
||||||
def run_job(job: Job) -> tuple[bool, str, Path, str | None, str | None]:
|
|
||||||
try:
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
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=(
|
|
||||||
"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(
|
|
||||||
"source",
|
|
||||||
nargs="?",
|
|
||||||
default=None,
|
|
||||||
type=Path,
|
|
||||||
help=f"Source root folder, single image, or .zip (default: {DEFAULT_SOURCE_DIR.as_posix()}/).",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-o",
|
|
||||||
"--output",
|
|
||||||
default=None,
|
|
||||||
dest="output_root",
|
|
||||||
type=Path,
|
|
||||||
help="Output root (default: same as source for folders/single file; for .zip default: <zip-stem>/).",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-j",
|
|
||||||
"--jobs",
|
|
||||||
type=int,
|
|
||||||
default=10,
|
|
||||||
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)
|
|
||||||
|
|
||||||
cwd = Path.cwd()
|
|
||||||
source = (
|
|
||||||
args.source.expanduser().resolve()
|
|
||||||
if args.source is not None
|
|
||||||
else (cwd / DEFAULT_SOURCE_DIR).resolve()
|
|
||||||
)
|
|
||||||
jobs: list[Job] = []
|
|
||||||
|
|
||||||
if source.is_dir():
|
|
||||||
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:
|
|
||||||
print(f"No new images to convert under: {source}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
converted = 0
|
|
||||||
skipped = 0
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
|
||||||
for ok, label, target, kind, error in pool.map(run_job, jobs):
|
|
||||||
if ok:
|
|
||||||
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:]))
|
|
||||||
@@ -3,8 +3,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/generate-manifest.mjs",
|
"build": "node scripts/generate-manifest.mjs",
|
||||||
"dev": "npm run build && npx --yes serve public -p 8788",
|
"build:manifest-from-source": "node scripts/generate-manifest.mjs --manifest-from-source",
|
||||||
"deploy": "npm run build && wrangler pages deploy public --project-name=meme-api"
|
"dev": "npm run build && npx --yes serve dist -p 8788",
|
||||||
|
"deploy": "npm run deploy:upload",
|
||||||
|
"deploy:upload": "npm run build && wrangler pages deploy dist --project-name=meme-api",
|
||||||
|
"deploy:preview": "npm run build && wrangler pages deploy dist --project-name=meme-api --branch preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"wrangler": "^4.6.0"
|
"wrangler": "^4.6.0"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>meme-api</h1>
|
<h1>meme-api</h1>
|
||||||
<p class="muted">静态站点(<a href="https://pages.cloudflare.com/">Cloudflare Pages</a>)。素材在仓库 <code>原图/</code> 内编号生成 <code>*.webp</code> / <code>*.gif</code>;<code>npm run build</code> 将 <code>原图/</code> 镜像到 <code>public/meme/</code> 用于部署。直链可被外部引用;JSON 已配置 CORS。</p>
|
<p class="muted">静态站点(<a href="https://pages.cloudflare.com/">Cloudflare Pages</a>)。在 <code>原图/</code> 放未编号素材后运行 <code>python 转化.py</code>,输出到 <code>原图-已处理/</code>。将成品拷入 <code>public/meme/</code> 后运行 <code>npm run build</code>:只生成 <code>memes.json</code> 并把 <code>public/</code> 复制到 <code>dist/</code>,<strong>不会</strong>改写 <code>public/meme/</code>。清单默认按 <code>public/meme/</code> 扫描;若尚未拷素材则回退扫描 <code>原图-已处理/</code>(无则 <code>原图/</code>)。需强制按源目录生成清单时用 <code>npm run build:manifest-from-source</code>。部署目录为 <code>dist/</code>。直链可外部引用;JSON 已配置 CORS。</p>
|
||||||
<p><a href="/memes/">浏览图库(分页、按文件夹分类、点击复制链接)</a></p>
|
<p><a href="/memes/">浏览图库(分页、按文件夹分类、点击复制链接)</a></p>
|
||||||
|
|
||||||
<h2>获取表情包列表</h2>
|
<h2>获取表情包列表</h2>
|
||||||
@@ -26,16 +26,17 @@
|
|||||||
<pre id="curl-memes">curl -sS ""</pre>
|
<pre id="curl-memes">curl -sS ""</pre>
|
||||||
|
|
||||||
<h2>直链访问单张表情包</h2>
|
<h2>直链访问单张表情包</h2>
|
||||||
<p>部署后文件在 <code>public/meme/<分类>/</code>(与仓库 <code>原图/<分类>/</code> 内容一致),对应 URL:</p>
|
<p>站点根目录对应构建产物 <code>dist/</code>(本地从 <code>public/</code> 同步而来);表情文件在 <code>/meme/<分类>/</code>,对应 URL:</p>
|
||||||
<p><code>GET /meme/<分类>/<文件名>.webp</code> 或 <code>.gif</code></p>
|
<p><code>GET /meme/<分类>/<文件名>.webp</code> 或 <code>.gif</code></p>
|
||||||
<pre id="curl-example">https://example.com/meme/%E7%99%BD%E8%89%B2%E5%B0%8F%E4%BA%BA/1.webp</pre>
|
<pre id="curl-example">https://example.com/meme/%E7%99%BD%E8%89%B2%E5%B0%8F%E4%BA%BA/1.webp</pre>
|
||||||
<p class="muted">中文或多段路径请使用编码后的 URL(浏览器会自动处理,图库「复制链接」亦为已编码地址)。</p>
|
<p class="muted">中文或多段路径请使用编码后的 URL(浏览器会自动处理,图库「复制链接」亦为已编码地址)。</p>
|
||||||
|
|
||||||
<h2>维护流程</h2>
|
<h2>维护流程</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>在 <code>原图/<分类>/</code> 放入未编号素材;运行 <code>python convert_to_webp.py</code>(就地生成编号文件,成功后会<strong>删除</strong>这些未编号源图;若需保留源文件可加 <code>--keep-sources</code>)。</li>
|
<li>在 <code>原图/<分类>/</code> 放入未编号素材;运行 <code>python 转化.py</code>,在 <code>原图-已处理/</code> 生成编号 <code>.webp</code>/<code>.gif</code> 并限最长边(默认 300px);默认<strong>保留</strong> <code>原图/</code> 源文件,清理可加 <code>--delete-sources</code>。</li>
|
||||||
<li>若仍有「动图 WebP」编号文件,可在 <code>原图/</code> 下运行 <code>python animated_webp_to_gif.py</code> 转为 <code>.gif</code>。</li>
|
<li>动图 WebP 会在 <code>python 转化.py</code> 转换流程中转为 GIF。</li>
|
||||||
<li><code>npm run build</code> 将 <code>原图/</code> 镜像到 <code>public/meme/</code> 并生成 <code>public/memes.json</code>,再部署 <code>public/</code>。</li>
|
<li><code>npm run build</code>:生成 <code>dist/</code>(含 <code>dist/memes.json</code>)。请先把 <code>原图-已处理/</code> 拷入 <code>public/meme/</code> 再构建;构建脚本不会覆盖 <code>public/meme/</code>。</li>
|
||||||
|
<li><strong>上线(生产)</strong>:项目在 Cloudflare 已连接 Git 时,由推送生产分支触发云端构建:<code>npm run build</code> → <code>git add/commit/push</code>;仪表盘「构建命令」填 <code>npm run build</code>,「构建输出目录」填 <code>dist</code>。<code>wrangler pages deploy dist</code> 在 Git 项目里常显示为<strong>预览</strong>,属正常。</li>
|
||||||
</ol>
|
</ol>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
BIN
public/meme/ai/19.webp
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/meme/ai/20.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/meme/ai/21.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/meme/cheems/2.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/meme/cheems/3.webp
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
public/meme/cheems/4.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/meme/cheems/5.webp
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/meme/emoji/85.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/meme/emoji/86.webp
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/meme/emoji/87.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/meme/emoji/88.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/meme/emoji/89.gif
Normal file
|
After Width: | Height: | Size: 476 KiB |
BIN
public/meme/emoji/90.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/meme/emoji/91.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/meme/emoji/92.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/meme/happycat/1767171915977.gif
Normal file
|
After Width: | Height: | Size: 529 KiB |
BIN
public/meme/happycat/1767172073425.gif
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
public/meme/happycat/1767172082144.gif
Normal file
|
After Width: | Height: | Size: 737 KiB |
BIN
public/meme/happycat/1767172125081.gif
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/meme/happycat/1767172151209.gif
Normal file
|
After Width: | Height: | Size: 618 KiB |
BIN
public/meme/happycat/1767172159397.gif
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
public/meme/happycat/1767172166055.gif
Normal file
|
After Width: | Height: | Size: 513 KiB |
BIN
public/meme/happycat/1767172166056.gif
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/meme/happycat/1767172166057.gif
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/meme/happycat/1767172166058.gif
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
public/meme/happycat/1767172166059.gif
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/meme/happycat/1767172166060.gif
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/meme/happycat/1767172166061.gif
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/meme/happycat/1767172166062.gif
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
public/meme/happycat/1767172166063.gif
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
public/meme/happycat/1767172166064.gif
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/meme/happycat/1767172166065.gif
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/meme/happycat/1767172166066.gif
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/meme/happycat/1767172166067.gif
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
public/meme/happycat/1767172166068.gif
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/meme/happycat/1767172166069.gif
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/meme/happycat/1767172166070.gif
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/meme/happycat/1767172166071.gif
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
public/meme/happycat/1767172166072.gif
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/meme/happycat/1767172166073.gif
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/meme/happycat/1767172166074.gif
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/meme/happycat/1767172166075.gif
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
public/meme/happycat/1767172166076.gif
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
public/meme/happycat/1767172166077.gif
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/meme/happycat/1767172166078.gif
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
public/meme/happycat/1767172166079.gif
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/meme/happycat/1767172166080.gif
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/meme/happycat/1767172166081.gif
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
public/meme/happycat/1767172166082.gif
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
public/meme/happycat/1767172166083.gif
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
public/meme/happycat/1767172166084.gif
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/meme/happycat/1767172166085.gif
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/meme/happycat/1767172166086.gif
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/meme/happycat/1767172166087.gif
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/meme/happycat/1767172166088.gif
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/meme/happycat/1767172166089.gif
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
public/meme/happycat/1767172166090.gif
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/meme/happycat/1767172166091.gif
Normal file
|
After Width: | Height: | Size: 336 KiB |
BIN
public/meme/happycat/1767172166092.gif
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/meme/happycat/1767172166093.gif
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/meme/happycat/1767172166094.gif
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
public/meme/happycat/1767172166095.gif
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/meme/happycat/1767172166096.gif
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
public/meme/happycat/1767172166097.gif
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/meme/happycat/1767172166098.gif
Normal file
|
After Width: | Height: | Size: 913 KiB |
BIN
public/meme/happycat/1767172166099.gif
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/meme/happycat/1767172166100.gif
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/meme/happycat/1767172166101.gif
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/meme/happycat/1767172166102.gif
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
public/meme/happycat/1767172166103.gif
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/meme/happycat/1767172166104.gif
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/meme/happycat/1767172166105.gif
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/meme/happycat/1767172166106.gif
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/meme/happycat/1767172166107.gif
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/meme/happycat/1767172166108.gif
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/meme/happycat/1767172166109.gif
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/meme/happycat/1767172166110.gif
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/meme/happycat/1767172166111.gif
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
public/meme/happycat/1767172166112.gif
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/meme/happycat/1767172166113.gif
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/meme/happycat/1767172166114.gif
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
public/meme/happycat/1767172166115.gif
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/meme/happycat/1767172166116.gif
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/meme/happycat/1767172166117.gif
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/meme/happycat/1767172166118.gif
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/meme/happycat/1767172166119.gif
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
public/meme/happycat/1767172166120.gif
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/meme/happycat/1767172166121.gif
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/meme/happycat/1767172166122.gif
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/meme/happycat/1767172166123.gif
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/meme/happycat/1767172166124.gif
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/meme/happycat/1767172166125.gif
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
public/meme/happycat/1767172166126.gif
Normal file
|
After Width: | Height: | Size: 46 KiB |