#!/usr/bin/env python3 """Convert the repository's runtime image assets to WebP. This is intentionally a one-time migration helper. It keeps the source files untouched and writes converted files into a caller-provided temporary folder. """ from __future__ import annotations import argparse import hashlib import json import os from pathlib import Path from PIL import Image, ImageOps IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"} LOSSLESS_PATH_PARTS = {"routes", "train-intro", "volumes", "products", "source"} def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--root", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--manifest", type=Path, required=True) return parser.parse_args() def source_files(root: Path) -> list[tuple[Path, str]]: public_root = root / "public" / "assets" files: list[tuple[Path, str]] = [] for path in sorted(public_root.rglob("*")): if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS: relative = path.relative_to(public_root).as_posix() files.append((path, relative)) logo = root / "apps" / "miniprogram" / "src" / "assets" / "wanderq-logo.png" if logo.is_file(): files.append((logo, "brand/wanderq-logo.png")) return files def should_use_lossless(source: Path, relative: str) -> bool: if source.suffix.lower() == ".png": return True parts = set(Path(relative).parts) return bool(parts & LOSSLESS_PATH_PARTS) or relative.endswith("train/price-overview.jpg") def convert(source: Path, target: Path, lossless: bool) -> None: target.parent.mkdir(parents=True, exist_ok=True) with Image.open(source) as opened: image = ImageOps.exif_transpose(opened) save_options = { "format": "WEBP", "method": 6, "lossless": lossless, } if not lossless: save_options["quality"] = 84 icc_profile = image.info.get("icc_profile") if icc_profile: save_options["icc_profile"] = icc_profile image.save(target, **save_options) def main() -> None: args = parse_args() root = args.root.resolve() output = args.output.resolve() output.mkdir(parents=True, exist_ok=True) manifest: list[dict[str, object]] = [] files = source_files(root) if not files: raise SystemExit("未找到本地静态图片源;为避免生成空清单,请确认源素材仍在 public/assets 或小程序 logo 路径中") for source, relative in files: output_relative = f"{Path(relative).with_suffix('')}.webp" target = output / output_relative lossless = should_use_lossless(source, relative) convert(source, target, lossless) source_bytes = source.stat().st_size output_bytes = target.stat().st_size digest = hashlib.sha256(target.read_bytes()).hexdigest() if relative == "brand/wanderq-logo.png": reference = None object_key = "wanqu/miniapp/static/brand/wanderq-logo.webp" source_path = "apps/miniprogram/src/assets/wanderq-logo.png" else: reference = f"/assets/{relative}" object_key = f"wanqu/miniapp/static/{output_relative}" source_path = f"public/assets/{relative}" manifest.append( { "sourcePath": source_path, "reference": reference, "outputPath": output_relative, "objectKey": object_key, "lossless": lossless, "sourceBytes": source_bytes, "outputBytes": output_bytes, "sha256": digest, } ) args.manifest.parent.mkdir(parents=True, exist_ok=True) args.manifest.write_text(json.dumps({"assets": manifest}, ensure_ascii=False, indent=2) + os.linesep) source_total = sum(int(item["sourceBytes"]) for item in manifest) output_total = sum(int(item["outputBytes"]) for item in manifest) print( json.dumps( { "assets": len(manifest), "sourceBytes": source_total, "outputBytes": output_total, "savedBytes": source_total - output_total, "lossless": sum(1 for item in manifest if item["lossless"]), "lossy": sum(1 for item in manifest if not item["lossless"]), }, ensure_ascii=False, ) ) if __name__ == "__main__": main()