- 重写 README.md,添加在线体验、API 文档、部署指南、技术栈等 - 添加 MIT LICENSE - 添加 CONTRIBUTING.md 贡献指南 - 添加 GitHub Issue/PR 模板 - 完善 .gitignore,防止原始图片和敏感文件误提交 - 从 git 中移除 25 个原始图片文件(.png/.jpg/非序号 webp) - 项目结构迁移:functions/ -> src/,根目录静态文件 -> public/ - 添加 wrangler.toml 与 package.json 配置
217 lines
6.5 KiB
Python
217 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import venv
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
PUBLIC_DIR = BASE_DIR / "public"
|
||
MANIFEST_PATH = PUBLIC_DIR / "manifest.json"
|
||
|
||
VARIANTS = {
|
||
"mobile": {
|
||
"out": PUBLIC_DIR / "mobile-image",
|
||
"scan": [
|
||
PUBLIC_DIR / "mobile-image",
|
||
BASE_DIR / "mobile-image",
|
||
],
|
||
},
|
||
"desktop": {
|
||
"out": PUBLIC_DIR / "desketop-image",
|
||
"scan": [
|
||
PUBLIC_DIR / "desketop-image",
|
||
BASE_DIR / "desketop-image",
|
||
],
|
||
},
|
||
}
|
||
|
||
INPUT_EXTENSIONS = {
|
||
".jpg",
|
||
".jpeg",
|
||
".png",
|
||
".bmp",
|
||
".gif",
|
||
".tif",
|
||
".tiff",
|
||
".webp",
|
||
}
|
||
|
||
GENERATED_NAME_RE = re.compile(r"^\d+\.webp$", re.IGNORECASE)
|
||
VENV_DIR = BASE_DIR / ".build-venv"
|
||
|
||
|
||
def ensure_pillow() -> None:
|
||
try:
|
||
from PIL import Image # noqa: F401
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
venv_python = VENV_DIR / "bin" / "python"
|
||
if not venv_python.exists():
|
||
print("Pillow not found, creating a local build venv...", file=sys.stderr)
|
||
builder = venv.EnvBuilder(with_pip=True)
|
||
builder.create(VENV_DIR)
|
||
|
||
has_pillow = subprocess.run(
|
||
[str(venv_python), "-c", "import PIL"],
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
check=False,
|
||
).returncode == 0
|
||
|
||
if not has_pillow:
|
||
print("Installing Pillow into the local build venv...", file=sys.stderr)
|
||
subprocess.check_call([str(venv_python), "-m", "pip", "install", "--quiet", "Pillow"])
|
||
os.execv(str(venv_python), [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]])
|
||
|
||
|
||
def load_pillow():
|
||
ensure_pillow()
|
||
from PIL import Image, ImageOps
|
||
|
||
return Image, ImageOps
|
||
|
||
|
||
def iter_source_files(directory: Path) -> list[Path]:
|
||
if not directory.exists():
|
||
return []
|
||
files: list[Path] = []
|
||
for path in sorted(directory.iterdir(), key=lambda item: item.name.lower()):
|
||
if not path.is_file():
|
||
continue
|
||
if GENERATED_NAME_RE.match(path.name):
|
||
continue
|
||
if path.suffix.lower() not in INPUT_EXTENSIONS:
|
||
continue
|
||
files.append(path)
|
||
return files
|
||
|
||
|
||
def select_scan_directory(scan_candidates: list[Path]) -> Path:
|
||
"""优先有原图的目录,其次有序号 webp;否则退回 scan 第一项(通常为 public下的输出目录)。"""
|
||
for directory in scan_candidates:
|
||
if iter_source_files(directory):
|
||
return directory
|
||
for directory in scan_candidates:
|
||
if sorted_generated_webp_names(directory):
|
||
return directory
|
||
return scan_candidates[0]
|
||
|
||
|
||
def clear_generated_files(directory: Path) -> None:
|
||
if not directory.exists():
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
return
|
||
for path in directory.iterdir():
|
||
if path.is_file() and GENERATED_NAME_RE.match(path.name):
|
||
path.unlink()
|
||
|
||
|
||
def sorted_generated_webp_names(directory: Path) -> list[str]:
|
||
"""已存在的序号 webp(无原图时再跑脚本不会清空)。"""
|
||
if not directory.exists():
|
||
return []
|
||
names = [
|
||
path.name
|
||
for path in directory.iterdir()
|
||
if path.is_file() and GENERATED_NAME_RE.match(path.name)
|
||
]
|
||
|
||
def sort_key(entry: str) -> int:
|
||
return int(Path(entry).stem)
|
||
|
||
names.sort(key=sort_key)
|
||
return names
|
||
|
||
|
||
def convert_to_webp(source: Path, target: Path, image_mod, image_ops) -> None:
|
||
with image_mod.open(source) as image:
|
||
image = image_ops.exif_transpose(image)
|
||
if image.mode not in {"RGB", "RGBA", "LA", "P"}:
|
||
image = image.convert("RGBA")
|
||
elif image.mode == "P":
|
||
image = image.convert("RGBA")
|
||
elif image.mode == "LA":
|
||
image = image.convert("RGBA")
|
||
elif image.mode == "RGB":
|
||
pass
|
||
image.save(target, format="WEBP", quality=92, method=6)
|
||
|
||
|
||
def copy_generated_webps(source_dir: Path, out_dir: Path, names: list[str]) -> None:
|
||
"""将已有序号 webp 同步到 public 下的部署目录。"""
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
for entry in names:
|
||
shutil.copy2(source_dir / entry, out_dir / entry)
|
||
|
||
|
||
def build_variant(name: str, spec: dict[str, Path | list[Path]], image_mod, image_ops) -> dict:
|
||
scan_dir = select_scan_directory(list(spec["scan"]))
|
||
out_dir = spec["out"]
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
sources = iter_source_files(scan_dir)
|
||
|
||
images: list[str] = []
|
||
if sources:
|
||
clear_generated_files(out_dir)
|
||
for index, source in enumerate(sources, start=1):
|
||
target = out_dir / f"{index}.webp"
|
||
try:
|
||
convert_to_webp(source, target, image_mod, image_ops)
|
||
images.append(target.name)
|
||
rel_src = os.path.relpath(source.resolve(), BASE_DIR.resolve())
|
||
print(f"[{name}] {rel_src} -> {target.name}")
|
||
except Exception as exc:
|
||
print(f"[{name}] skip {source.name}: {exc}", file=sys.stderr)
|
||
else:
|
||
images = sorted_generated_webp_names(out_dir)
|
||
if not images and scan_dir.resolve() != out_dir.resolve():
|
||
legacy = sorted_generated_webp_names(scan_dir)
|
||
if legacy:
|
||
copy_generated_webps(scan_dir, out_dir, legacy)
|
||
images = legacy
|
||
print(f"[{name}] 已从 {scan_dir.relative_to(BASE_DIR)} 复制序号 webp ×{len(images)} -> public/")
|
||
if images:
|
||
if scan_dir.resolve() == out_dir.resolve():
|
||
print(f"[{name}] 未检测到原图,保留 public 下序号 webp ×{len(images)}")
|
||
else:
|
||
print(f"[{name}] 跳过:未发现原图,且 public/ 与归档目录均无 1.webp 等序号文件")
|
||
|
||
return {
|
||
"folder": out_dir.name,
|
||
"count": len(images),
|
||
"images": images,
|
||
}
|
||
|
||
|
||
def write_manifest(manifest: dict) -> None:
|
||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
|
||
|
||
def main() -> int:
|
||
image_mod, image_ops = load_pillow()
|
||
|
||
manifest = {
|
||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||
"variants": {},
|
||
}
|
||
|
||
for name, variant_spec in VARIANTS.items():
|
||
manifest["variants"][name] = build_variant(name, variant_spec, image_mod, image_ops)
|
||
|
||
write_manifest(manifest)
|
||
print(f"Wrote manifest: {MANIFEST_PATH}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|