133 lines
4.5 KiB
Python
133 lines
4.5 KiB
Python
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageOps
|
|
|
|
import jsonfile
|
|
from jsonfile import PROJECT_ROOT
|
|
|
|
SOURCE_DIR = Path.home() / "dotfiles" / "wallpapers"
|
|
OUTPUT_DIR = PROJECT_ROOT / "static" / "wallpapers" / "gallery"
|
|
THUMB_DIR = OUTPUT_DIR / "thumbs"
|
|
PUBLIC_PATH = "/wallpapers/gallery"
|
|
MANIFEST_FILE = "wallpapers.json"
|
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
|
FULL_SIZE = (2560, 1440)
|
|
THUMB_SIZE = (480, 270)
|
|
FULL_QUALITY = 82
|
|
THUMB_QUALITY = 70
|
|
SMALL_IMAGE_EDGE = 1000
|
|
|
|
|
|
def slug_of(path: Path) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "-", path.stem.lower()).strip("-")
|
|
|
|
|
|
def display_name(path: Path) -> str:
|
|
words = re.sub(r"[_\-]+", " ", path.stem).strip()
|
|
return re.sub(r"^\d+\s+", "", words).title()
|
|
|
|
|
|
def source_images(source_dir: Path) -> list[Path]:
|
|
return sorted(path for path in source_dir.iterdir() if path.suffix.lower() in IMAGE_EXTENSIONS and path.is_file())
|
|
|
|
|
|
def flatten(image: Image.Image) -> Image.Image:
|
|
image = ImageOps.exif_transpose(image)
|
|
if image.mode in ("RGBA", "LA", "P"):
|
|
image = image.convert("RGBA")
|
|
background = Image.new("RGB", image.size, (0, 0, 0))
|
|
background.paste(image, mask=image.getchannel("A"))
|
|
return background
|
|
return image.convert("RGB")
|
|
|
|
|
|
def resample_for(image: Image.Image) -> int:
|
|
is_pixel_art = max(image.size) <= SMALL_IMAGE_EDGE
|
|
return Image.Resampling.NEAREST if is_pixel_art else Image.Resampling.LANCZOS
|
|
|
|
|
|
def fit_within(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|
scale = min(size[0] / image.width, size[1] / image.height)
|
|
if scale >= 1 and max(image.size) > SMALL_IMAGE_EDGE:
|
|
return image
|
|
target = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
|
|
return image.resize(target, resample_for(image))
|
|
|
|
|
|
def save_jpeg(image: Image.Image, path: Path, quality: int):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(path, "JPEG", quality=quality, optimize=True, progressive=True)
|
|
|
|
|
|
def is_up_to_date(source: Path, outputs: list[Path]) -> bool:
|
|
return all(output.exists() and output.stat().st_mtime >= source.stat().st_mtime for output in outputs)
|
|
|
|
|
|
def convert(source: Path, force: bool) -> dict:
|
|
slug = slug_of(source)
|
|
full_path = OUTPUT_DIR / f"{slug}.jpg"
|
|
thumb_path = THUMB_DIR / f"{slug}.jpg"
|
|
|
|
if force or not is_up_to_date(source, [full_path, thumb_path]):
|
|
with Image.open(source) as opened:
|
|
image = flatten(opened)
|
|
save_jpeg(fit_within(image, FULL_SIZE), full_path, FULL_QUALITY)
|
|
save_jpeg(ImageOps.fit(image, THUMB_SIZE, resample_for(image)), thumb_path, THUMB_QUALITY)
|
|
|
|
return {
|
|
"id": slug,
|
|
"name": display_name(source),
|
|
"image": f"{PUBLIC_PATH}/{slug}.jpg",
|
|
"thumb": f"{PUBLIC_PATH}/thumbs/{slug}.jpg",
|
|
}
|
|
|
|
|
|
def remove_stale(kept_slugs: set[str]) -> int:
|
|
removed = 0
|
|
for folder in (OUTPUT_DIR, THUMB_DIR):
|
|
for path in folder.glob("*.jpg"):
|
|
if path.stem not in kept_slugs:
|
|
path.unlink()
|
|
removed += 1
|
|
return removed
|
|
|
|
|
|
def folder_size(folder: Path) -> int:
|
|
return sum(path.stat().st_size for path in folder.rglob("*") if path.is_file())
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Compress wallpapers from the dotfiles repo into JPEGs for the website")
|
|
parser.add_argument("source", nargs="?", default=str(SOURCE_DIR), help="folder with the original wallpapers")
|
|
parser.add_argument("--force", action="store_true", help="convert every wallpaper again")
|
|
arguments = parser.parse_args()
|
|
|
|
source_dir = Path(arguments.source).expanduser()
|
|
if not source_dir.is_dir():
|
|
print(f"{source_dir} is not a folder", file=sys.stderr)
|
|
return 1
|
|
|
|
wallpapers = []
|
|
for source in source_images(source_dir):
|
|
try:
|
|
wallpapers.append(convert(source, arguments.force))
|
|
print(f"ok {source.name}")
|
|
except OSError as error:
|
|
print(f"skipped {source.name}: {error}", file=sys.stderr)
|
|
|
|
removed = remove_stale({wallpaper["id"] for wallpaper in wallpapers})
|
|
jsonfile.save(MANIFEST_FILE, wallpapers)
|
|
|
|
original_size = sum(path.stat().st_size for path in source_images(source_dir))
|
|
compressed_size = folder_size(OUTPUT_DIR)
|
|
print(f"\n{len(wallpapers)} wallpapers, {removed} stale files removed")
|
|
print(f"{original_size / 1024 / 1024:.1f} MB -> {compressed_size / 1024 / 1024:.1f} MB (including thumbnails)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|